Skip to content

Commit 051aa48

Browse files
n-ccn-cc
andauthored
feat[mysql_user]: add support for mysql user attributes (#604)
* add support for mysql user attributes * fix CI * write integration tests * requested changes pt. 1 * requested changes pt. 2 * fix changelog fragment --------- Co-authored-by: n-cc <[email protected]>
1 parent 81ab18d commit 051aa48

File tree

6 files changed

+644
-59
lines changed

6 files changed

+644
-59
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
minor_changes:
2+
- "mysql_user - add user attribute support via the ``attributes`` parameter and return value (https://github.com/ansible-collections/community.mysql/pull/604)."

plugins/module_utils/user.py

Lines changed: 141 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# Simplified BSD License (see simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)
1111

1212
import string
13+
import json
1314
import re
1415

1516
from ansible.module_utils.six import iteritems
@@ -151,13 +152,17 @@ def get_existing_authentication(cursor, user, host):
151152

152153
def 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

211223
def is_hash(password):
@@ -218,7 +230,7 @@ def is_hash(password):
218230

219231
def 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

445494
def 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+
9271015
def get_impl(cursor):
9281016
global impl
9291017
cursor.execute("SELECT VERSION()")

plugins/modules/mysql_role.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -931,7 +931,7 @@ def update(self, users, privs, check_mode=False,
931931
if privs:
932932
result = user_mod(self.cursor, self.name, self.host,
933933
None, None, None, None, None, None,
934-
privs, append_privs, subtract_privs, None,
934+
privs, append_privs, subtract_privs, None, None,
935935
self.module, role=True, maria_role=self.is_mariadb)
936936
changed = result['changed']
937937

plugins/modules/mysql_user.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,6 @@
155155
- Cannot be used to set global variables, use the M(community.mysql.mysql_variables) module instead.
156156
type: dict
157157
version_added: '3.6.0'
158-
159158
column_case_sensitive:
160159
description:
161160
- The default is C(false).
@@ -165,6 +164,13 @@
165164
fields names in privileges.
166165
type: bool
167166
version_added: '3.8.0'
167+
attributes:
168+
description:
169+
- "Create, update, or delete user attributes (arbitrary 'key: value' comments) for the user."
170+
- MySQL server must support the INFORMATION_SCHEMA.USER_ATTRIBUTES table. Provided since MySQL 8.0.
171+
- To delete an existing attribute, set its value to null.
172+
type: dict
173+
version_added: '3.9.0'
168174
169175
notes:
170176
- "MySQL server installs with default I(login_user) of C(root) and no password.
@@ -257,6 +263,13 @@
257263
FUNCTION my_db.my_function: EXECUTE
258264
state: present
259265
266+
- name: Modify user attributes, creating the attribute 'foo' and removing the attribute 'bar'
267+
community.mysql.mysql_user:
268+
name: bob
269+
attributes:
270+
foo: "foo"
271+
bar: null
272+
260273
- name: Modify user to require TLS connection with a valid client certificate
261274
community.mysql.mysql_user:
262275
name: bob
@@ -405,6 +418,7 @@ def main():
405418
tls_requires=dict(type='dict'),
406419
append_privs=dict(type='bool', default=False),
407420
subtract_privs=dict(type='bool', default=False),
421+
attributes=dict(type='dict'),
408422
check_implicit_admin=dict(type='bool', default=False),
409423
update_password=dict(type='str', default='always', choices=['always', 'on_create', 'on_new_username'], no_log=False),
410424
sql_log_bin=dict(type='bool', default=True),
@@ -437,6 +451,7 @@ def main():
437451
append_privs = module.boolean(module.params["append_privs"])
438452
subtract_privs = module.boolean(module.params['subtract_privs'])
439453
update_password = module.params['update_password']
454+
attributes = module.params['attributes']
440455
ssl_cert = module.params["client_cert"]
441456
ssl_key = module.params["client_key"]
442457
ssl_ca = module.params["ca_cert"]
@@ -500,21 +515,23 @@ def main():
500515

501516
priv = privileges_unpack(priv, mode, column_case_sensitive, ensure_usage=not subtract_privs)
502517
password_changed = False
518+
final_attributes = None
503519
if state == "present":
504520
if user_exists(cursor, user, host, host_all):
505521
try:
506522
if update_password == "always":
507523
result = user_mod(cursor, user, host, host_all, password, encrypted,
508524
plugin, plugin_hash_string, plugin_auth_string,
509-
priv, append_privs, subtract_privs, tls_requires, module)
525+
priv, append_privs, subtract_privs, attributes, tls_requires, module)
510526

511527
else:
512528
result = user_mod(cursor, user, host, host_all, None, encrypted,
513529
None, None, None,
514-
priv, append_privs, subtract_privs, tls_requires, module)
530+
priv, append_privs, subtract_privs, attributes, tls_requires, module)
515531
changed = result['changed']
516532
msg = result['msg']
517533
password_changed = result['password_changed']
534+
final_attributes = result['attributes']
518535

519536
except (SQLParseError, InvalidPrivsError, mysql_driver.Error) as e:
520537
module.fail_json(msg=to_native(e))
@@ -527,9 +544,10 @@ def main():
527544
reuse_existing_password = update_password == 'on_new_username'
528545
result = user_add(cursor, user, host, host_all, password, encrypted,
529546
plugin, plugin_hash_string, plugin_auth_string,
530-
priv, tls_requires, module.check_mode, reuse_existing_password)
547+
priv, attributes, tls_requires, reuse_existing_password, module)
531548
changed = result['changed']
532549
password_changed = result['password_changed']
550+
final_attributes = result['attributes']
533551
if changed:
534552
msg = "User added"
535553

@@ -546,7 +564,7 @@ def main():
546564
else:
547565
changed = False
548566
msg = "User doesn't exist"
549-
module.exit_json(changed=changed, user=user, msg=msg, password_changed=password_changed)
567+
module.exit_json(changed=changed, user=user, msg=msg, password_changed=password_changed, attributes=final_attributes)
550568

551569

552570
if __name__ == '__main__':

tests/integration/targets/test_mysql_user/tasks/main.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,9 @@
267267
tags:
268268
- issue_465
269269

270+
# Tests for user attributes
271+
- include_tasks: test_user_attributes.yml
272+
270273
# Tests for the TLS requires dictionary
271274
- include_tasks: test_tls_requirements.yml
272275

0 commit comments

Comments
 (0)