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

mysql_user: add "update_password: on_new_username" argument, "password_changed" result field #365

Merged
merged 12 commits into from
May 31, 2022
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
minor_changes:
- >
mysql_user - Add the option ``on_new_username`` to argument ``update_password`` to reuse the password (plugin and
authentication_string) when creating a new user if some user with the same name already exists.
If the existing user with the same name have varying passwords, the password from the arguments is used like with
``update_password: always`` (https://github.com/ansible-collections/community.mysql/pull/365).
- >
mysql_user - Add the result field ``password_changed`` (boolean). It is true, when the user got a new password.
When the user was created with ``update_password: on_new_username`` and an existing password was reused,
``password_changed`` is false (https://github.com/ansible-collections/community.mysql/pull/365).
51 changes: 41 additions & 10 deletions plugins/module_utils/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,21 +112,49 @@ def get_grants(cursor, user, host):
return grants.split(", ")


def get_existing_authentication(cursor, user):
# Return the plugin and auth_string if there is exactly one distinct existing plugin and auth_string.
cursor.execute("SELECT VERSION()")
if 'mariadb' in cursor.fetchone()[0].lower():
# before MariaDB 10.2.19 and 10.3.11, "password" and "authentication_string" can differ
# when using mysql_native_password
cursor.execute("""select plugin, auth from (
select plugin, password as auth from mysql.user where user=%(user)s
union select plugin, authentication_string as auth from mysql.user where user=%(user)s
) x group by plugin, auth limit 2
""", {'user': user})
else:
cursor.execute("""select plugin, authentication_string as auth from mysql.user where user=%(user)s
group by plugin, authentication_string limit 2""", {'user': user})
rows = cursor.fetchall()
if len(rows) == 1:
return {'plugin': rows[0][0], 'auth_string': rows[0][1]}
return None


def user_add(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string, new_priv,
tls_requires, check_mode):
tls_requires, check_mode, reuse_existing_password):
# we cannot create users without a proper hostname
if host_all:
return False
return {'changed': False, 'password_changed': False}

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

# Determine what user management method server uses
old_user_mgmt = impl.use_old_user_mgmt(cursor)

mogrify = do_not_mogrify_requires if old_user_mgmt else mogrify_requires

used_existing_password = False
if reuse_existing_password:
existing_auth = get_existing_authentication(cursor, user)
if existing_auth:
plugin = existing_auth['plugin']
plugin_hash_string = existing_auth['auth_string']
password = None
used_existing_password = True
if password and encrypted:
if impl.supports_identified_by_password(cursor):
query_with_args = "CREATE USER %s@%s IDENTIFIED BY PASSWORD %s", (user, host, password)
Expand Down Expand Up @@ -156,7 +184,7 @@ def user_add(cursor, user, host, host_all, password, encrypted,
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
return {'changed': True, 'password_changed': not used_existing_password}


def is_hash(password):
Expand All @@ -182,6 +210,7 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
else:
hostnames = [host]

password_changed = False
for host in hostnames:
# Handle clear text and hashed passwords.
if not role:
Expand Down Expand Up @@ -226,9 +255,10 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
encrypted_password = cursor.fetchone()[0]

if current_pass_hash != encrypted_password:
password_changed = True
msg = "Password updated"
if module.check_mode:
return (True, msg)
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)"
Expand Down Expand Up @@ -280,6 +310,7 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
query_with_args = "ALTER USER %s@%s IDENTIFIED WITH %s", (user, host, plugin)

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

# Handle privileges
Expand All @@ -297,7 +328,7 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
if user != "root" and "PROXY" not in priv:
msg = "Privileges updated"
if module.check_mode:
return (True, msg)
return {'changed': True, 'msg': msg, 'password_changed': password_changed}
privileges_revoke(cursor, user, host, db_table, priv, grant_option, maria_role)
changed = True

Expand All @@ -308,7 +339,7 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
if db_table not in curr_priv:
msg = "New privileges granted"
if module.check_mode:
return (True, msg)
return {'changed': True, 'msg': msg, 'password_changed': password_changed}
privileges_grant(cursor, user, host, db_table, priv, tls_requires, maria_role)
changed = True

Expand Down Expand Up @@ -338,7 +369,7 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
if len(grant_privs) + len(revoke_privs) > 0:
msg = "Privileges updated: granted %s, revoked %s" % (grant_privs, revoke_privs)
if module.check_mode:
return (True, msg)
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:
Expand All @@ -353,7 +384,7 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
if current_requires != tls_requires:
msg = "TLS requires updated"
if module.check_mode:
return (True, msg)
return {'changed': True, 'msg': msg, 'password_changed': password_changed}
if not old_user_mgmt:
pre_query = "ALTER USER"
else:
Expand All @@ -369,7 +400,7 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
cursor.execute(*query_with_args)
changed = True

return (changed, msg)
return {'changed': changed, 'msg': msg, 'password_changed': password_changed}


def user_delete(cursor, user, host, host_all, check_mode):
Expand Down
9 changes: 5 additions & 4 deletions plugins/modules/mysql_role.py
Original file line number Diff line number Diff line change
Expand Up @@ -911,10 +911,11 @@ def update(self, users, privs, check_mode=False,
set_default_role_all=set_default_role_all)

if privs:
changed, msg = user_mod(self.cursor, self.name, self.host,
None, None, None, None, None, None,
privs, append_privs, subtract_privs, None,
self.module, role=True, maria_role=self.is_mariadb)
result = user_mod(self.cursor, self.name, self.host,
None, None, None, None, None, None,
privs, append_privs, subtract_privs, None,
self.module, role=True, maria_role=self.is_mariadb)
changed = result['changed']

if admin:
self.role_impl.set_admin(admin)
Expand Down
37 changes: 24 additions & 13 deletions plugins/modules/mysql_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,12 @@
description:
- C(always) will update passwords if they differ. This affects I(password) and the combination of I(plugin), I(plugin_hash_string), I(plugin_auth_string).
- C(on_create) will only set the password or the combination of plugin, plugin_hash_string, plugin_auth_string for newly created users.
- "C(on_new_username) works like C(on_create), but it tries to reuse an existing password: If one different user
with the same username exists, or multiple different users with the same username and equal C(plugin) and
C(authentication_string) attribute, the existing C(plugin) and C(authentication_string) are used for the
new user instead of the I(password), I(plugin), I(plugin_hash_string) or I(plugin_auth_string) argument."
type: str
choices: [ always, on_create ]
choices: [ always, on_create, on_new_username ]
default: always
plugin:
description:
Expand Down Expand Up @@ -370,7 +374,7 @@ def main():
append_privs=dict(type='bool', default=False),
subtract_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),
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),
plugin=dict(default=None, type='str'),
plugin_hash_string=dict(default=None, type='str'),
Expand Down Expand Up @@ -447,18 +451,22 @@ def main():
except Exception as e:
module.fail_json(msg=to_native(e))
priv = privileges_unpack(priv, mode, ensure_usage=not subtract_privs)

password_changed = False
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, subtract_privs, tls_requires, module)
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)

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

except (SQLParseError, InvalidPrivsError, mysql_driver.Error) as e:
module.fail_json(msg=to_native(e))
Expand All @@ -468,9 +476,12 @@ def main():
try:
if subtract_privs:
priv = None # avoid granting unwanted privileges
changed = 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 = 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)
changed = result['changed']
password_changed = result['password_changed']
if changed:
msg = "User added"

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


if __name__ == '__main__':
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
- name: "applying user {{ username }}@{{ host }} with update_password={{ update_password }}"
mysql_user:
login_user: '{{ mysql_parameters.login_user }}'
login_password: '{{ mysql_parameters.login_password }}'
login_host: '{{ mysql_parameters.login_host }}'
login_port: '{{ mysql_parameters.login_port }}'
state: present
name: "{{ username }}"
host: "{{ host }}"
password: "{{ password }}"
update_password: "{{ update_password }}"
register: result
- name: assert a change occurred
assert:
that:
- "result.changed == {{ expect_change }}"
- "result.password_changed == {{ expect_password_change }}"
- name: query the user
command: "{{ mysql_command }} -BNe \"SELECT plugin, authentication_string FROM mysql.user where user='{{ username }}' and host='{{ host }}'\""
register: existing_user
- name: assert the password is as set to expect_hash
assert:
that:
- "'mysql_native_password\t{{ expect_password_hash }}' in existing_user.stdout_lines"
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Tests scenarios for both plaintext and encrypted user passwords.

- vars:
mysql_parameters:
login_user: '{{ mysql_user }}'
login_password: '{{ mysql_password }}'
login_host: 127.0.0.1
login_port: '{{ mysql_primary_port }}'
test_password1: kbB9tcx5WOGVGfzV
test_password1_hash: '*AF6A7F9D038475C17EE46564F154104877EE5037'
test_password2: XBYjpHmjIctMxl1y
test_password2_hash: '*9E22D1B35C68BDDF398B8F28AE482E5A865BAC0A'
test_password3: tem33JfR5Yx98BB
test_password3_hash: '*C7E7C2710702F20336F8D93BC0670C8FB66BDBC7'


block:
- include_tasks: assert_user_password.yml
vars:
username: "{{ item.username }}"
host: '127.0.0.1'
update_password: "{{ item.update_password }}"
password: "{{ test_password1 }}"
expect_change: "{{ item.expect_change }}"
expect_password_change: "{{ item.expect_change }}"
expect_password_hash: "{{ test_password1_hash }}"
loop:
# all variants set the password when nothing exists
- username: test1
update_password: always
expect_change: true
- username: test2
update_password: on_create
expect_change: true
- username: test3
update_password: on_new_username
expect_change: true

# assert idempotency
- username: test1
update_password: always
expect_change: false
- username: test2
update_password: on_create
expect_change: false
- username: test3
update_password: on_new_username
expect_change: false

# same user, new password
- include_tasks: assert_user_password.yml
vars:
username: "{{ item.username }}"
host: '127.0.0.1'
update_password: "{{ item.update_password }}"
password: "{{ test_password2 }}"
expect_change: "{{ item.expect_change }}"
expect_password_change: "{{ item.expect_change }}"
expect_password_hash: "{{ item.expect_password_hash }}"
loop:
- username: test1
update_password: always
expect_change: true
expect_password_hash: "{{ test_password2_hash }}"
- username: test2
update_password: on_create
expect_change: false
expect_password_hash: "{{ test_password1_hash }}"
- username: test3
update_password: on_new_username
expect_change: false
expect_password_hash: "{{ test_password1_hash }}"

# new user, new password
- include_tasks: assert_user_password.yml
vars:
username: "{{ item.username }}"
host: '::1'
update_password: "{{ item.update_password }}"
password: "{{ item.password }}"
expect_change: "{{ item.expect_change }}"
expect_password_change: "{{ item.expect_password_change }}"
expect_password_hash: "{{ item.expect_password_hash }}"
loop:
- username: test1
update_password: always
expect_change: true
expect_password_change: true
password: "{{ test_password1 }}"
expect_password_hash: "{{ test_password1_hash }}"
- username: test2
update_password: on_create
expect_change: true
expect_password_change: true
password: "{{ test_password2 }}"
expect_password_hash: "{{ test_password2_hash }}"
- username: test3
update_password: on_new_username
expect_change: true
expect_password_change: false
password: "{{ test_password2 }}"
expect_password_hash: "{{ test_password1_hash }}"

# prepare for next test: ensure all users have varying passwords
- username: test3
update_password: always
expect_change: true
expect_password_change: true
password: "{{ test_password2 }}"
expect_password_hash: "{{ test_password2_hash }}"

# another new user, another new password and multiple existing users with varying passwords
- include_tasks: assert_user_password.yml
vars:
username: "{{ item.username }}"
host: '2001:db8::1'
update_password: "{{ item.update_password }}"
password: "{{ test_password3 }}"
expect_change: true
expect_password_change: true
expect_password_hash: "{{ test_password3_hash }}"
loop:
- username: test1
update_password: always
- username: test2
update_password: on_create
- username: test3
update_password: on_new_username