mysql_user: add "update_password: on_new_username" argument, "password_changed" result field ()

* mysql_user: add value 'on_new_username' to argument 'update_password'

* mysql_user: return "password_changed" boolean (true if the user got a new password)

* mysql_user: optimize queries for existing passwords

* mysql_user: add integration tests for update_password argument

* mysql_user: add description for "update_password: on_new_username" argument

* add changelog fragment

* formatting (PEP8)

* Update changelogs/fragments/365-mysql_user-add-on_new_username-and-password_changed.yml

Co-authored-by: Benjamin MALYNOVYTCH <bmalynovytch@users.noreply.github.com>

* Update changelogs/fragments/365-mysql_user-add-on_new_username-and-password_changed.yml

Co-authored-by: Benjamin MALYNOVYTCH <bmalynovytch@users.noreply.github.com>

* Update plugins/modules/mysql_user.py

Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>

* Update changelogs/fragments/365-mysql_user-add-on_new_username-and-password_changed.yml

Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>

* Update changelogs/fragments/365-mysql_user-add-on_new_username-and-password_changed.yml

Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>

Co-authored-by: Felix Hamme <felix.hamme@ionos.com>
Co-authored-by: Benjamin MALYNOVYTCH <bmalynovytch@users.noreply.github.com>
Co-authored-by: Andrew Klychkov <aaklychkov@mail.ru>
This commit is contained in:
betanummeric 2022-05-31 16:00:24 +02:00 committed by GitHub
parent 51a38840d9
commit ed3935abec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 232 additions and 27 deletions

View file

@ -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).

View file

@ -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)
@ -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):
@ -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:
@ -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)"
@ -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
@ -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
@ -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
@ -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:
@ -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:
@ -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):

View file

@ -911,10 +911,11 @@ class Role():
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)

View file

@ -118,8 +118,12 @@ options:
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:
@ -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'),
@ -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))
@ -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"
@ -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__':

View file

@ -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"

View file

@ -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