1010# Simplified BSD License (see simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)
1111
1212import string
13+ import json
1314import re
1415
1516from ansible .module_utils .six import iteritems
@@ -151,13 +152,17 @@ def get_existing_authentication(cursor, user, host):
151152
152153def user_add (cursor , user , host , host_all , password , encrypted ,
153154 plugin , plugin_hash_string , plugin_auth_string , new_priv ,
154- tls_requires , check_mode , reuse_existing_password ):
155+ attributes , tls_requires , reuse_existing_password , module ):
156+ # If attributes are set, perform a sanity check to ensure server supports user attributes before creating user
157+ if attributes and not get_attribute_support (cursor ):
158+ module .fail_json (msg = "user attributes were specified but the server does not support user attributes" )
159+
155160 # we cannot create users without a proper hostname
156161 if host_all :
157- return {'changed' : False , 'password_changed' : False }
162+ return {'changed' : False , 'password_changed' : False , 'attributes' : attributes }
158163
159- if check_mode :
160- return {'changed' : True , 'password_changed' : None }
164+ if module . check_mode :
165+ return {'changed' : True , 'password_changed' : None , 'attributes' : attributes }
161166
162167 # Determine what user management method server uses
163168 old_user_mgmt = impl .use_old_user_mgmt (cursor )
@@ -205,7 +210,14 @@ def user_add(cursor, user, host, host_all, password, encrypted,
205210 privileges_grant (cursor , user , host , db_table , priv , tls_requires )
206211 if tls_requires is not None :
207212 privileges_grant (cursor , user , host , "*.*" , get_grants (cursor , user , host ), tls_requires )
208- return {'changed' : True , 'password_changed' : not used_existing_password }
213+
214+ final_attributes = None
215+
216+ if attributes :
217+ cursor .execute ("ALTER USER %s@%s ATTRIBUTE %s" , (user , host , json .dumps (attributes )))
218+ final_attributes = attributes_get (cursor , user , host )
219+
220+ return {'changed' : True , 'password_changed' : not used_existing_password , 'attributes' : final_attributes }
209221
210222
211223def is_hash (password ):
@@ -218,7 +230,7 @@ def is_hash(password):
218230
219231def user_mod (cursor , user , host , host_all , password , encrypted ,
220232 plugin , plugin_hash_string , plugin_auth_string , new_priv ,
221- append_privs , subtract_privs , tls_requires , module , role = False , maria_role = False ):
233+ append_privs , subtract_privs , attributes , tls_requires , module , role = False , maria_role = False ):
222234 changed = False
223235 msg = "User unchanged"
224236 grant_option = False
@@ -278,27 +290,26 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
278290 if current_pass_hash != encrypted_password :
279291 password_changed = True
280292 msg = "Password updated"
281- if module .check_mode :
282- return {'changed' : True , 'msg' : msg , 'password_changed' : password_changed }
283- if old_user_mgmt :
284- cursor .execute ("SET PASSWORD FOR %s@%s = %s" , (user , host , encrypted_password ))
285- msg = "Password updated (old style)"
286- else :
287- try :
288- cursor .execute ("ALTER USER %s@%s IDENTIFIED WITH mysql_native_password AS %s" , (user , host , encrypted_password ))
289- msg = "Password updated (new style)"
290- except (mysql_driver .Error ) as e :
291- # https://stackoverflow.com/questions/51600000/authentication-string-of-root-user-on-mysql
292- # Replacing empty root password with new authentication mechanisms fails with error 1396
293- if e .args [0 ] == 1396 :
294- cursor .execute (
295- "UPDATE mysql.user SET plugin = %s, authentication_string = %s, Password = '' WHERE User = %s AND Host = %s" ,
296- ('mysql_native_password' , encrypted_password , user , host )
297- )
298- cursor .execute ("FLUSH PRIVILEGES" )
299- msg = "Password forced update"
300- else :
301- raise e
293+ if not module .check_mode :
294+ if old_user_mgmt :
295+ cursor .execute ("SET PASSWORD FOR %s@%s = %s" , (user , host , encrypted_password ))
296+ msg = "Password updated (old style)"
297+ else :
298+ try :
299+ cursor .execute ("ALTER USER %s@%s IDENTIFIED WITH mysql_native_password AS %s" , (user , host , encrypted_password ))
300+ msg = "Password updated (new style)"
301+ except (mysql_driver .Error ) as e :
302+ # https://stackoverflow.com/questions/51600000/authentication-string-of-root-user-on-mysql
303+ # Replacing empty root password with new authentication mechanisms fails with error 1396
304+ if e .args [0 ] == 1396 :
305+ cursor .execute (
306+ "UPDATE mysql.user SET plugin = %s, authentication_string = %s, Password = '' WHERE User = %s AND Host = %s" ,
307+ ('mysql_native_password' , encrypted_password , user , host )
308+ )
309+ cursor .execute ("FLUSH PRIVILEGES" )
310+ msg = "Password forced update"
311+ else :
312+ raise e
302313 changed = True
303314
304315 # Handle plugin authentication
@@ -352,9 +363,8 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
352363 if db_table not in new_priv :
353364 if user != "root" and "PROXY" not in priv :
354365 msg = "Privileges updated"
355- if module .check_mode :
356- return {'changed' : True , 'msg' : msg , 'password_changed' : password_changed }
357- privileges_revoke (cursor , user , host , db_table , priv , grant_option , maria_role )
366+ if not module .check_mode :
367+ privileges_revoke (cursor , user , host , db_table , priv , grant_option , maria_role )
358368 changed = True
359369
360370 # If the user doesn't currently have any privileges on a db.table, then
@@ -363,9 +373,8 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
363373 for db_table , priv in iteritems (new_priv ):
364374 if db_table not in curr_priv :
365375 msg = "New privileges granted"
366- if module .check_mode :
367- return {'changed' : True , 'msg' : msg , 'password_changed' : password_changed }
368- privileges_grant (cursor , user , host , db_table , priv , tls_requires , maria_role )
376+ if not module .check_mode :
377+ privileges_grant (cursor , user , host , db_table , priv , tls_requires , maria_role )
369378 changed = True
370379
371380 # If the db.table specification exists in both the user's current privileges
@@ -404,42 +413,82 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
404413
405414 if len (grant_privs ) + len (revoke_privs ) > 0 :
406415 msg = "Privileges updated: granted %s, revoked %s" % (grant_privs , revoke_privs )
407- if module .check_mode :
408- return {'changed' : True , 'msg' : msg , 'password_changed' : password_changed }
409- if len (revoke_privs ) > 0 :
410- privileges_revoke (cursor , user , host , db_table , revoke_privs , grant_option , maria_role )
411- if len (grant_privs ) > 0 :
412- privileges_grant (cursor , user , host , db_table , grant_privs , tls_requires , maria_role )
416+ if not module .check_mode :
417+ if len (revoke_privs ) > 0 :
418+ privileges_revoke (cursor , user , host , db_table , revoke_privs , grant_option , maria_role )
419+ if len (grant_privs ) > 0 :
420+ privileges_grant (cursor , user , host , db_table , grant_privs , tls_requires , maria_role )
421+ else :
422+ changed = True
413423
414424 # after privilege manipulation, compare privileges from before and now
415425 after_priv = privileges_get (cursor , user , host , maria_role )
416426 changed = changed or (curr_priv != after_priv )
417427
428+ # Handle attributes
429+ attribute_support = get_attribute_support (cursor )
430+ final_attributes = {}
431+
432+ if attributes :
433+ if not attribute_support :
434+ module .fail_json (msg = "user attributes were specified but the server does not support user attributes" )
435+ else :
436+ current_attributes = attributes_get (cursor , user , host )
437+
438+ if current_attributes is None :
439+ current_attributes = {}
440+
441+ attributes_to_change = {}
442+
443+ for key , value in attributes .items ():
444+ if key not in current_attributes or current_attributes [key ] != value :
445+ attributes_to_change [key ] = value
446+
447+ if attributes_to_change :
448+ msg = "Attributes updated: %s" % (", " .join (["%s: %s" % (key , value ) for key , value in attributes_to_change .items ()]))
449+
450+ # Calculate final attributes by re-running attributes_get when not in check mode, and merge dictionaries when in check mode
451+ if not module .check_mode :
452+ cursor .execute ("ALTER USER %s@%s ATTRIBUTE %s" , (user , host , json .dumps (attributes_to_change )))
453+ final_attributes = attributes_get (cursor , user , host )
454+ else :
455+ # Final if statements excludes items whose values are None in attributes_to_change, i.e. attributes that will be deleted
456+ final_attributes = {k : v for d in (current_attributes , attributes_to_change ) for k , v in d .items () if k not in attributes_to_change or
457+ attributes_to_change [k ] is not None }
458+
459+ # Convert empty dict to None per return value requirements
460+ final_attributes = final_attributes if final_attributes else None
461+ changed = True
462+ else :
463+ final_attributes = current_attributes
464+ else :
465+ if attribute_support :
466+ final_attributes = attributes_get (cursor , user , host )
467+
418468 if role :
419469 continue
420470
421471 # Handle TLS requirements
422472 current_requires = get_tls_requires (cursor , user , host )
423473 if current_requires != tls_requires :
424474 msg = "TLS requires updated"
425- if module .check_mode :
426- return {'changed' : True , 'msg' : msg , 'password_changed' : password_changed }
427- if not old_user_mgmt :
428- pre_query = "ALTER USER"
429- else :
430- pre_query = "GRANT %s ON *.* TO" % "," .join (get_grants (cursor , user , host ))
475+ if not module .check_mode :
476+ if not old_user_mgmt :
477+ pre_query = "ALTER USER"
478+ else :
479+ pre_query = "GRANT %s ON *.* TO" % "," .join (get_grants (cursor , user , host ))
431480
432- if tls_requires is not None :
433- query = " " .join ((pre_query , "%s@%s" ))
434- query_with_args = mogrify_requires (query , (user , host ), tls_requires )
435- else :
436- query = " " .join ((pre_query , "%s@%s REQUIRE NONE" ))
437- query_with_args = query , (user , host )
481+ if tls_requires is not None :
482+ query = " " .join ((pre_query , "%s@%s" ))
483+ query_with_args = mogrify_requires (query , (user , host ), tls_requires )
484+ else :
485+ query = " " .join ((pre_query , "%s@%s REQUIRE NONE" ))
486+ query_with_args = query , (user , host )
438487
439- cursor .execute (* query_with_args )
488+ cursor .execute (* query_with_args )
440489 changed = True
441490
442- return {'changed' : changed , 'msg' : msg , 'password_changed' : password_changed }
491+ return {'changed' : changed , 'msg' : msg , 'password_changed' : password_changed , 'attributes' : final_attributes }
443492
444493
445494def user_delete (cursor , user , host , host_all , check_mode ):
@@ -924,6 +973,45 @@ def limit_resources(module, cursor, user, host, resource_limits, check_mode):
924973 return True
925974
926975
976+ def get_attribute_support (cursor ):
977+ """Checks if the MySQL server supports user attributes.
978+
979+ Args:
980+ cursor (cursor): DB driver cursor object.
981+ Returns:
982+ True if attributes are supported, False if they are not.
983+ """
984+ try :
985+ # information_schema.tables does not hold the tables within information_schema itself
986+ cursor .execute ("SELECT attribute FROM INFORMATION_SCHEMA.USER_ATTRIBUTES LIMIT 0" )
987+ cursor .fetchone ()
988+ except mysql_driver .Error :
989+ return False
990+
991+ return True
992+
993+
994+ def attributes_get (cursor , user , host ):
995+ """Get attributes for a given user.
996+
997+ Args:
998+ cursor (cursor): DB driver cursor object.
999+ user (str): User name.
1000+ host (str): User host name.
1001+
1002+ Returns:
1003+ None if the user does not exist or the user has no attributes set, otherwise a dict of attributes set on the user
1004+ """
1005+ cursor .execute ("SELECT attribute FROM INFORMATION_SCHEMA.USER_ATTRIBUTES WHERE user = %s AND host = %s" , (user , host ))
1006+
1007+ r = cursor .fetchone ()
1008+ # convert JSON string stored in row into a dict - mysql enforces that user_attributes entires are in JSON format
1009+ j = json .loads (r [0 ]) if r and r [0 ] else None
1010+
1011+ # if the attributes dict is empty, return None instead
1012+ return j if j else None
1013+
1014+
9271015def get_impl (cursor ):
9281016 global impl
9291017 cursor .execute ("SELECT VERSION()" )
0 commit comments