Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat[mysql_user]: add support for mysql user attributes #604

Merged
merged 6 commits into from
Jan 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelogs/fragments/604-user-attributes.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
minor_changes:
- "mysql_user - add user attribute support via the ``attributes`` parameter and return value (https://github.com/ansible-collections/community.mysql/pull/604)."
194 changes: 141 additions & 53 deletions plugins/module_utils/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
# Simplified BSD License (see simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)

import string
import json
import re

from ansible.module_utils.six import iteritems
Expand Down Expand Up @@ -151,13 +152,17 @@

def user_add(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string, new_priv,
tls_requires, check_mode, reuse_existing_password):
attributes, tls_requires, reuse_existing_password, module):
# If attributes are set, perform a sanity check to ensure server supports user attributes before creating user
if attributes and not get_attribute_support(cursor):
module.fail_json(msg="user attributes were specified but the server does not support user attributes")

# we cannot create users without a proper hostname
if host_all:
return {'changed': False, 'password_changed': False}
return {'changed': False, 'password_changed': False, 'attributes': attributes}

Check warning on line 162 in plugins/module_utils/user.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/user.py#L162

Added line #L162 was not covered by tests

if check_mode:
return {'changed': True, 'password_changed': None}
if module.check_mode:
return {'changed': True, 'password_changed': None, 'attributes': attributes}

# Determine what user management method server uses
old_user_mgmt = impl.use_old_user_mgmt(cursor)
Expand Down Expand Up @@ -205,7 +210,14 @@
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 {'changed': True, 'password_changed': not used_existing_password}

final_attributes = None

if attributes:
cursor.execute("ALTER USER %s@%s ATTRIBUTE %s", (user, host, json.dumps(attributes)))
final_attributes = attributes_get(cursor, user, host)

return {'changed': True, 'password_changed': not used_existing_password, 'attributes': final_attributes}


def is_hash(password):
Expand All @@ -218,7 +230,7 @@

def user_mod(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string, new_priv,
append_privs, subtract_privs, tls_requires, module, role=False, maria_role=False):
append_privs, subtract_privs, attributes, tls_requires, module, role=False, maria_role=False):
changed = False
msg = "User unchanged"
grant_option = False
Expand Down Expand Up @@ -278,27 +290,26 @@
if current_pass_hash != encrypted_password:
password_changed = True
msg = "Password updated"
if module.check_mode:
return {'changed': True, 'msg': msg, 'password_changed': password_changed}
if old_user_mgmt:
cursor.execute("SET PASSWORD FOR %s@%s = %s", (user, host, encrypted_password))
msg = "Password updated (old style)"
else:
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 mysql.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
if not module.check_mode:
if old_user_mgmt:
cursor.execute("SET PASSWORD FOR %s@%s = %s", (user, host, encrypted_password))
msg = "Password updated (old style)"

Check warning on line 296 in plugins/module_utils/user.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/user.py#L295-L296

Added lines #L295 - L296 were not covered by tests
else:
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:

Check warning on line 301 in plugins/module_utils/user.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/user.py#L301

Added line #L301 was not covered by tests
# 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(

Check warning on line 305 in plugins/module_utils/user.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/user.py#L305

Added line #L305 was not covered by tests
"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("FLUSH PRIVILEGES")
msg = "Password forced update"

Check warning on line 310 in plugins/module_utils/user.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/user.py#L309-L310

Added lines #L309 - L310 were not covered by tests
else:
raise e

Check warning on line 312 in plugins/module_utils/user.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/user.py#L312

Added line #L312 was not covered by tests
changed = True

# Handle plugin authentication
Expand Down Expand Up @@ -352,9 +363,8 @@
if db_table not in new_priv:
if user != "root" and "PROXY" not in priv:
msg = "Privileges updated"
if module.check_mode:
return {'changed': True, 'msg': msg, 'password_changed': password_changed}
privileges_revoke(cursor, user, host, db_table, priv, grant_option, maria_role)
if not module.check_mode:
privileges_revoke(cursor, user, host, db_table, priv, grant_option, maria_role)
changed = True

# If the user doesn't currently have any privileges on a db.table, then
Expand All @@ -363,9 +373,8 @@
for db_table, priv in iteritems(new_priv):
if db_table not in curr_priv:
msg = "New privileges granted"
if module.check_mode:
return {'changed': True, 'msg': msg, 'password_changed': password_changed}
privileges_grant(cursor, user, host, db_table, priv, tls_requires, maria_role)
if not module.check_mode:
privileges_grant(cursor, user, host, db_table, priv, tls_requires, maria_role)
changed = True

# If the db.table specification exists in both the user's current privileges
Expand Down Expand Up @@ -404,42 +413,82 @@

if len(grant_privs) + len(revoke_privs) > 0:
msg = "Privileges updated: granted %s, revoked %s" % (grant_privs, revoke_privs)
if module.check_mode:
return {'changed': True, 'msg': msg, 'password_changed': password_changed}
if len(revoke_privs) > 0:
privileges_revoke(cursor, user, host, db_table, revoke_privs, grant_option, maria_role)
if len(grant_privs) > 0:
privileges_grant(cursor, user, host, db_table, grant_privs, tls_requires, maria_role)
if not module.check_mode:
if len(revoke_privs) > 0:
privileges_revoke(cursor, user, host, db_table, revoke_privs, grant_option, maria_role)
if len(grant_privs) > 0:
privileges_grant(cursor, user, host, db_table, grant_privs, tls_requires, maria_role)
else:
changed = True

# after privilege manipulation, compare privileges from before and now
after_priv = privileges_get(cursor, user, host, maria_role)
changed = changed or (curr_priv != after_priv)

# Handle attributes
attribute_support = get_attribute_support(cursor)
final_attributes = {}

if attributes:
if not attribute_support:
module.fail_json(msg="user attributes were specified but the server does not support user attributes")

Check warning on line 434 in plugins/module_utils/user.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/user.py#L434

Added line #L434 was not covered by tests
else:
current_attributes = attributes_get(cursor, user, host)

if current_attributes is None:
current_attributes = {}

Check warning on line 439 in plugins/module_utils/user.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/user.py#L439

Added line #L439 was not covered by tests

attributes_to_change = {}

for key, value in attributes.items():
if key not in current_attributes or current_attributes[key] != value:
attributes_to_change[key] = value

if attributes_to_change:
msg = "Attributes updated: %s" % (", ".join(["%s: %s" % (key, value) for key, value in attributes_to_change.items()]))

# Calculate final attributes by re-running attributes_get when not in check mode, and merge dictionaries when in check mode
if not module.check_mode:
cursor.execute("ALTER USER %s@%s ATTRIBUTE %s", (user, host, json.dumps(attributes_to_change)))
final_attributes = attributes_get(cursor, user, host)
else:
# Final if statements excludes items whose values are None in attributes_to_change, i.e. attributes that will be deleted
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
attributes_to_change[k] is not None}

# Convert empty dict to None per return value requirements
final_attributes = final_attributes if final_attributes else None
changed = True
else:
final_attributes = current_attributes
else:
if attribute_support:
final_attributes = attributes_get(cursor, user, host)

if role:
continue

# 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 {'changed': True, 'msg': msg, 'password_changed': password_changed}
if not old_user_mgmt:
pre_query = "ALTER USER"
else:
pre_query = "GRANT %s ON *.* TO" % ",".join(get_grants(cursor, user, host))
if not module.check_mode:
if not old_user_mgmt:
pre_query = "ALTER USER"
else:
pre_query = "GRANT %s ON *.* TO" % ",".join(get_grants(cursor, user, host))

Check warning on line 479 in plugins/module_utils/user.py

View check run for this annotation

Codecov / codecov/patch

plugins/module_utils/user.py#L479

Added line #L479 was not covered by tests

if tls_requires is not None:
query = " ".join((pre_query, "%s@%s"))
query_with_args = mogrify_requires(query, (user, host), tls_requires)
else:
query = " ".join((pre_query, "%s@%s REQUIRE NONE"))
query_with_args = query, (user, host)
if tls_requires is not None:
query = " ".join((pre_query, "%s@%s"))
query_with_args = mogrify_requires(query, (user, host), tls_requires)
else:
query = " ".join((pre_query, "%s@%s REQUIRE NONE"))
query_with_args = query, (user, host)

cursor.execute(*query_with_args)
cursor.execute(*query_with_args)
changed = True

return {'changed': changed, 'msg': msg, 'password_changed': password_changed}
return {'changed': changed, 'msg': msg, 'password_changed': password_changed, 'attributes': final_attributes}


def user_delete(cursor, user, host, host_all, check_mode):
Expand Down Expand Up @@ -924,6 +973,45 @@
return True


def get_attribute_support(cursor):
"""Checks if the MySQL server supports user attributes.

Args:
cursor (cursor): DB driver cursor object.
Returns:
True if attributes are supported, False if they are not.
"""
try:
# information_schema.tables does not hold the tables within information_schema itself
cursor.execute("SELECT attribute FROM INFORMATION_SCHEMA.USER_ATTRIBUTES LIMIT 0")
cursor.fetchone()
except mysql_driver.Error:
return False

return True


def attributes_get(cursor, user, host):
"""Get attributes for a given user.

Args:
cursor (cursor): DB driver cursor object.
user (str): User name.
host (str): User host name.

Returns:
None if the user does not exist or the user has no attributes set, otherwise a dict of attributes set on the user
"""
cursor.execute("SELECT attribute FROM INFORMATION_SCHEMA.USER_ATTRIBUTES WHERE user = %s AND host = %s", (user, host))

r = cursor.fetchone()
# convert JSON string stored in row into a dict - mysql enforces that user_attributes entires are in JSON format
j = json.loads(r[0]) if r and r[0] else None

# if the attributes dict is empty, return None instead
return j if j else None


def get_impl(cursor):
global impl
cursor.execute("SELECT VERSION()")
Expand Down
2 changes: 1 addition & 1 deletion plugins/modules/mysql_role.py
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,7 @@ def update(self, users, privs, check_mode=False,
if privs:
result = user_mod(self.cursor, self.name, self.host,
None, None, None, None, None, None,
privs, append_privs, subtract_privs, None,
privs, append_privs, subtract_privs, None, None,
self.module, role=True, maria_role=self.is_mariadb)
changed = result['changed']

Expand Down
28 changes: 23 additions & 5 deletions plugins/modules/mysql_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,6 @@
- Cannot be used to set global variables, use the M(community.mysql.mysql_variables) module instead.
type: dict
version_added: '3.6.0'

column_case_sensitive:
description:
- The default is C(false).
Expand All @@ -165,6 +164,13 @@
fields names in privileges.
type: bool
version_added: '3.8.0'
attributes:
description:
- "Create, update, or delete user attributes (arbitrary 'key: value' comments) for the user."
- MySQL server must support the INFORMATION_SCHEMA.USER_ATTRIBUTES table. Provided since MySQL 8.0.
- To delete an existing attribute, set its value to null.
type: dict
version_added: '3.9.0'
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not bump the version in galaxy.yml yet; unsure what the process is for shipping new versions.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3.9.0 is OK here


notes:
- "MySQL server installs with default I(login_user) of C(root) and no password.
Expand Down Expand Up @@ -257,6 +263,13 @@
FUNCTION my_db.my_function: EXECUTE
state: present

- name: Modify user attributes, creating the attribute 'foo' and removing the attribute 'bar'
community.mysql.mysql_user:
name: bob
attributes:
foo: "foo"
bar: null

- name: Modify user to require TLS connection with a valid client certificate
community.mysql.mysql_user:
name: bob
Expand Down Expand Up @@ -405,6 +418,7 @@ def main():
tls_requires=dict(type='dict'),
append_privs=dict(type='bool', default=False),
subtract_privs=dict(type='bool', default=False),
attributes=dict(type='dict'),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can change the parameter ordering here if desired.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's fine now

check_implicit_admin=dict(type='bool', default=False),
update_password=dict(type='str', default='always', choices=['always', 'on_create', 'on_new_username'], no_log=False),
sql_log_bin=dict(type='bool', default=True),
Expand Down Expand Up @@ -437,6 +451,7 @@ def main():
append_privs = module.boolean(module.params["append_privs"])
subtract_privs = module.boolean(module.params['subtract_privs'])
update_password = module.params['update_password']
attributes = module.params['attributes']
ssl_cert = module.params["client_cert"]
ssl_key = module.params["client_key"]
ssl_ca = module.params["ca_cert"]
Expand Down Expand Up @@ -500,21 +515,23 @@ def main():

priv = privileges_unpack(priv, mode, column_case_sensitive, ensure_usage=not subtract_privs)
password_changed = False
final_attributes = None
if state == "present":
if user_exists(cursor, user, host, host_all):
try:
if update_password == "always":
result = user_mod(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string,
priv, append_privs, subtract_privs, tls_requires, module)
priv, append_privs, subtract_privs, attributes, tls_requires, module)

else:
result = user_mod(cursor, user, host, host_all, None, encrypted,
None, None, None,
priv, append_privs, subtract_privs, tls_requires, module)
priv, append_privs, subtract_privs, attributes, tls_requires, module)
changed = result['changed']
msg = result['msg']
password_changed = result['password_changed']
final_attributes = result['attributes']

except (SQLParseError, InvalidPrivsError, mysql_driver.Error) as e:
module.fail_json(msg=to_native(e))
Expand All @@ -527,9 +544,10 @@ def main():
reuse_existing_password = update_password == 'on_new_username'
result = user_add(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string,
priv, tls_requires, module.check_mode, reuse_existing_password)
priv, attributes, tls_requires, reuse_existing_password, module)
changed = result['changed']
password_changed = result['password_changed']
final_attributes = result['attributes']
if changed:
msg = "User added"

Expand All @@ -546,7 +564,7 @@ def main():
else:
changed = False
msg = "User doesn't exist"
module.exit_json(changed=changed, user=user, msg=msg, password_changed=password_changed)
module.exit_json(changed=changed, user=user, msg=msg, password_changed=password_changed, attributes=final_attributes)


if __name__ == '__main__':
Expand Down
3 changes: 3 additions & 0 deletions tests/integration/targets/test_mysql_user/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,9 @@
tags:
- issue_465

# Tests for user attributes
- include_tasks: test_user_attributes.yml

# Tests for the TLS requires dictionary
- include_tasks: test_tls_requirements.yml

Expand Down
Loading