User locking (#702)

* function to check if a user is locked already

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Add the location and logic of where I think user locking would happen.

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Fix missing parameters for execute()

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Add the locked attribute

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Initial user locking integration tests

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Add attribute documentation

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* More descriptive names in the integration tests

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* - Changes requested/suggested by @Andersson007
- Example usage
- Changelog fragment

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Fix user_is_locked and remove host_all option.

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Fix host of user (was % should have been localhost after deleting `host:` earlier)

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Switch locked to named instead of positional.

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Add check_mode support.

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Add check_mode: true test cases

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Fix names that included `check_mode: true`

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Add idempotence checks

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Switch calls to user_mod with sequences of None positional arguments to full named arguments

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* locked check should not run for roles.

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* check_mode is set at the task level and not the module level

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Add user locking to info module and test.

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Handle DictCursor

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>


* Add check_mode feedback

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>


* Add another builtin account to the exclusion list

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Initial switch to default=None for locked, will need to add a test for it.

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>

* Add check that missing locked argument does not unlock a user

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>
---------

Signed-off-by: E.S. Rosenberg a.k.a. Keeper of the Keys <es.rosenberg+github@gmail.com>
This commit is contained in:
Keeper-of-the-Keys 2025-03-19 15:40:59 +02:00 committed by GitHub
parent dd7e297d50
commit 45a29408ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 285 additions and 14 deletions

View file

@ -0,0 +1,2 @@
minor_changes:
- mysql_user - add ``locked`` option to lock/unlock users, this is mainly used to have users that will act as definers on stored procedures.

View file

@ -52,6 +52,25 @@ def user_exists(cursor, user, host, host_all):
return count[0] > 0
def user_is_locked(cursor, user, host):
cursor.execute("SHOW CREATE USER %s@%s", (user, host))
# Per discussions on irc:libera.chat:#maria the query may return up to 2 rows but "ACCOUNT LOCK" should always be in the first row.
result = cursor.fetchone()
# ACCOUNT LOCK does not have to be the last option in the CREATE USER query.
# Need to handle both DictCursor and non-DictCursor
if isinstance(result, tuple):
if result[0].find('ACCOUNT LOCK') > 0:
return True
elif isinstance(result, dict):
for res in result.values():
if res.find('ACCOUNT LOCK') > 0:
return True
return False
def sanitize_requires(tls_requires):
sanitized_requires = {}
if tls_requires:
@ -160,7 +179,7 @@ def get_existing_authentication(cursor, user, host=None):
def user_add(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string, salt, new_priv,
attributes, tls_requires, reuse_existing_password, module,
password_expire, password_expire_interval):
password_expire, password_expire_interval, locked=False):
# 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")
@ -250,6 +269,9 @@ def user_add(cursor, user, host, host_all, password, encrypted,
cursor.execute("ALTER USER %s@%s ATTRIBUTE %s", (user, host, json.dumps(attributes)))
final_attributes = attributes_get(cursor, user, host)
if locked:
cursor.execute("ALTER USER %s@%s ACCOUNT LOCK", (user, host))
return {'changed': True, 'password_changed': not used_existing_password, 'attributes': final_attributes}
@ -264,7 +286,7 @@ def is_hash(password):
def user_mod(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string, salt, new_priv,
append_privs, subtract_privs, attributes, tls_requires, module,
password_expire, password_expire_interval, role=False, maria_role=False):
password_expire, password_expire_interval, locked=None, role=False, maria_role=False):
changed = False
msg = "User unchanged"
grant_option = False
@ -536,6 +558,22 @@ def user_mod(cursor, user, host, host_all, password, encrypted,
if attribute_support:
final_attributes = attributes_get(cursor, user, host)
if not role and locked is not None and user_is_locked(cursor, user, host) != locked:
if not module.check_mode:
if locked:
cursor.execute("ALTER USER %s@%s ACCOUNT LOCK", (user, host))
msg = 'User locked'
else:
cursor.execute("ALTER USER %s@%s ACCOUNT UNLOCK", (user, host))
msg = 'User unlocked'
else:
if locked:
msg = 'User will be locked'
else:
msg = 'User will be unlocked'
changed = True
if role:
continue

View file

@ -319,6 +319,7 @@ from ansible_collections.community.mysql.plugins.module_utils.user import (
get_resource_limits,
get_existing_authentication,
get_user_implementation,
user_is_locked,
)
from ansible.module_utils.six import iteritems
from ansible.module_utils._text import to_native
@ -653,8 +654,10 @@ class MySQL_Info(object):
if authentications:
output_dict.update(authentications[0])
if line.get('is_role') and line['is_role'] == 'N':
output_dict['locked'] = user_is_locked(self.cursor, user, host)
# TODO password_option
# TODO lock_option
# but both are not supported by mysql_user atm. So no point yet.
output.append(output_dict)

View file

@ -930,11 +930,12 @@ class Role():
set_default_role_all=set_default_role_all)
if privs:
result = user_mod(self.cursor, self.name, self.host,
None, None, None, None, None, None, None,
privs, append_privs, subtract_privs, None, None,
self.module, None, None, role=True,
maria_role=self.is_mariadb)
result = user_mod(cursor=self.cursor, user=self.name, host=self.host,
host_all=None, password=None, encrypted=None, plugin=None,
plugin_auth_string=None, plugin_hash_string=None, salt=None,
new_priv=privs, append_privs=append_privs, subtract_privs=subtract_privs,
attributes=None, tls_requires=None, module=self.module, password_expire=None,
password_expire_interval=None, role=True, maria_role=self.is_mariadb)
changed = result['changed']
if admin:

View file

@ -189,6 +189,15 @@ options:
fields names in privileges.
type: bool
version_added: '3.8.0'
locked:
description:
- Lock account to prevent connections using it.
- This is primarily used for creating a user that will act as a DEFINER on stored procedures.
- If not specified leaves the lock state as is (for a new user creates unlocked).
type: bool
version_added: '3.13.0'
attributes:
description:
- "Create, update, or delete user attributes (arbitrary 'key: value' comments) for the user."
@ -225,6 +234,7 @@ author:
- Lukasz Tomaszkiewicz (@tomaszkiewicz)
- kmarse (@kmarse)
- Laurent Indermühle (@laurent-indermuehle)
- E.S. Rosenberg (@Keeper-of-the-Keys)
extends_documentation_fragment:
- community.mysql.mysql
@ -400,6 +410,13 @@ EXAMPLES = r'''
priv:
'db1.*': DELETE
- name: Create locked user to act as a definer on procedures
community.mysql.mysql_user:
name: readonly_procedures_locked
locked: true
priv:
db1.*: SELECT
# Example .my.cnf file for setting the root password
# [client]
# user=root
@ -470,6 +487,7 @@ def main():
column_case_sensitive=dict(type='bool', default=None), # TODO 4.0.0 add default=True
password_expire=dict(type='str', choices=['now', 'never', 'default', 'interval'], no_log=True),
password_expire_interval=dict(type='int', required_if=[('password_expire', 'interval', True)], no_log=True),
locked=dict(type='bool'),
)
module = AnsibleModule(
argument_spec=argument_spec,
@ -510,6 +528,7 @@ def main():
column_case_sensitive = module.params["column_case_sensitive"]
password_expire = module.params["password_expire"]
password_expire_interval = module.params["password_expire_interval"]
locked = module.boolean(module.params['locked'])
if priv and not isinstance(priv, (str, dict)):
module.fail_json(msg="priv parameter must be str or dict but %s was passed" % type(priv))
@ -577,13 +596,15 @@ def main():
result = user_mod(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string, salt,
priv, append_privs, subtract_privs, attributes, tls_requires, module,
password_expire, password_expire_interval)
password_expire, password_expire_interval, locked=locked)
else:
result = user_mod(cursor, user, host, host_all, None, encrypted,
None, None, None, None,
priv, append_privs, subtract_privs, attributes, tls_requires, module,
password_expire, password_expire_interval)
result = user_mod(cursor=cursor, user=user, host=host, host_all=host_all, password=None,
encrypted=encrypted, plugin=None, plugin_hash_string=None, plugin_auth_string=None,
salt=None, new_priv=priv, append_privs=append_privs, subtract_privs=subtract_privs,
attributes=attributes, tls_requires=tls_requires, module=module,
password_expire=password_expire, password_expire_interval=password_expire_interval,
locked=locked)
changed = result['changed']
msg = result['msg']
password_changed = result['password_changed']
@ -601,7 +622,7 @@ def main():
result = user_add(cursor, user, host, host_all, password, encrypted,
plugin, plugin_hash_string, plugin_auth_string, salt,
priv, attributes, tls_requires, reuse_existing_password, module,
password_expire, password_expire_interval)
password_expire, password_expire_interval, locked=locked)
changed = result['changed']
password_changed = result['password_changed']
final_attributes = result['attributes']

View file

@ -261,6 +261,7 @@
resource_limits: "{{ item.resource_limits | default(omit) }}"
column_case_sensitive: true
state: present
locked: "{{ item.locked | default(omit) }}"
loop: "{{ result.users_info }}"
loop_control:
label: "{{ item.name }}@{{ item.host }}"
@ -275,6 +276,7 @@
- item.name != 'mariadb.sys'
- item.name != 'mysql.sys'
- item.name != 'mysql.infoschema'
- item.name != 'mysql.session'
# ================================== Cleanup ============================

View file

@ -305,3 +305,7 @@
- name: Mysql_user - test update_password
ansible.builtin.import_tasks:
file: test_update_password.yml
- name: Mysql_user - test user_locking
ansible.builtin.import_tasks:
file: test_user_locking.yml

View file

@ -0,0 +1,200 @@
---
- vars:
mysql_parameters: &mysql_params
login_user: '{{ mysql_user }}'
login_password: '{{ mysql_password }}'
login_host: '{{ mysql_host }}'
login_port: '{{ mysql_primary_port }}'
block:
# ========================= Prepare =======================================
- name: Mysql_user Lock user | Create a test database
community.mysql.mysql_db:
<<: *mysql_params
name: mysql_lock_user_test
state: present
# ========================== Tests ========================================
- name: Mysql_user Lock user | create locked | Create test user
community.mysql.mysql_user:
<<: *mysql_params
name: mysql_locked_user
password: 'msandbox'
locked: true
priv:
'mysql_lock_user_test.*': 'SELECT'
- name: Mysql_user Lock user | create locked | Assert that test user is locked
community.mysql.mysql_query:
<<: *mysql_params
query:
- SHOW CREATE USER 'mysql_locked_user'@'localhost'
register: locked_user_creation
failed_when:
- locked_user_creation.query_result[0][0] is not search('ACCOUNT LOCK')
- name: 'Mysql_user Lock user | create locked | Idempotence check'
check_mode: true
community.mysql.mysql_user:
<<: *mysql_params
name: mysql_locked_user
locked: true
priv:
'mysql_lock_user_test.*': 'SELECT'
register: idempotence_check
failed_when: idempotence_check is changed
- name: 'Mysql_user Lock user | create locked | Check that absense of locked does not unlock the user'
check_mode: true
community.mysql.mysql_user:
<<: *mysql_params
name: mysql_locked_user
priv:
'mysql_lock_user_test.*': 'SELECT'
register: idempotence_check
failed_when: idempotence_check is changed
- name: 'Mysql_user Lock user | create locked | Unlock test user check_mode: true'
check_mode: true
community.mysql.mysql_user:
<<: *mysql_params
name: mysql_locked_user
locked: false
priv:
'mysql_lock_user_test.*': 'SELECT'
- name: Mysql_user Lock user | create locked | Assert that test user is locked
community.mysql.mysql_query:
<<: *mysql_params
query:
- SHOW CREATE USER 'mysql_locked_user'@'localhost'
register: locked_user_creation
failed_when:
- locked_user_creation.query_result[0][0] is not search('ACCOUNT LOCK')
- name: Mysql_user Lock user | create locked | Unlock test user
community.mysql.mysql_user:
<<: *mysql_params
name: mysql_locked_user
locked: false
priv:
'mysql_lock_user_test.*': 'SELECT'
- name: Mysql_user Lock user | create locked | Assert that test user is not locked
community.mysql.mysql_query:
<<: *mysql_params
query:
- SHOW CREATE USER 'mysql_locked_user'@'localhost'
register: locked_user_creation
failed_when:
- locked_user_creation.query_result[0][0] is search('ACCOUNT LOCK')
- name: Mysql_user Lock user | create locked | Remove test user
community.mysql.mysql_user:
<<: *mysql_params
name: mysql_locked_user
state: absent
- name: Mysql_user Lock user | create unlocked | Create test user
community.mysql.mysql_user:
<<: *mysql_params
name: mysql_locked_user
password: 'msandbox'
locked: false
priv:
'mysql_lock_user_test.*': 'SELECT'
- name: Mysql_user Lock user | create unlocked | Assert that test user is not locked
community.mysql.mysql_query:
<<: *mysql_params
query:
- SHOW CREATE USER 'mysql_locked_user'@'localhost'
register: locked_user_creation
failed_when:
- locked_user_creation.query_result[0][0] is search('ACCOUNT LOCK')
- name: 'Mysql_user Lock user | create unlocked | Idempotence check'
check_mode: true
community.mysql.mysql_user:
<<: *mysql_params
name: mysql_locked_user
locked: false
priv:
'mysql_lock_user_test.*': 'SELECT'
register: idempotence_check
failed_when: idempotence_check is changed
- name: 'Mysql_user Lock user | create unlocked | Lock test user check_mode: true'
check_mode: true
community.mysql.mysql_user:
<<: *mysql_params
name: mysql_locked_user
locked: true
priv:
'mysql_lock_user_test.*': 'SELECT'
- name: Mysql_user Lock user | create unlocked | Assert that test user is not locked
community.mysql.mysql_query:
<<: *mysql_params
query:
- SHOW CREATE USER 'mysql_locked_user'@'localhost'
register: locked_user_creation
failed_when:
- locked_user_creation.query_result[0][0] is search('ACCOUNT LOCK')
- name: Mysql_user Lock user | create unlocked | Lock test user
community.mysql.mysql_user:
<<: *mysql_params
name: mysql_locked_user
locked: true
priv:
'mysql_lock_user_test.*': 'SELECT'
- name: Mysql_user Lock user | create unlocked | Assert that test user is locked
community.mysql.mysql_query:
<<: *mysql_params
query:
- SHOW CREATE USER 'mysql_locked_user'@'localhost'
register: locked_user_creation
failed_when:
- locked_user_creation.query_result[0][0] is not search('ACCOUNT LOCK')
- name: Mysql_user Lock user | create unlocked | Remove test user
community.mysql.mysql_user:
<<: *mysql_params
name: mysql_locked_user
state: absent
- name: Mysql_user Lock user | create default | Create test user
community.mysql.mysql_user:
<<: *mysql_params
name: mysql_locked_user
password: 'msandbox'
priv:
'mysql_lock_user_test.*': 'SELECT'
- name: Mysql_user Lock user | create default | Assert that test user is not locked
community.mysql.mysql_query:
<<: *mysql_params
query:
- SHOW CREATE USER 'mysql_locked_user'@'localhost'
register: locked_user_creation
failed_when:
- locked_user_creation.query_result[0][0] is search('ACCOUNT LOCK')
- name: Mysql_user Lock user | create default | Remove test user
community.mysql.mysql_user:
<<: *mysql_params
name: mysql_locked_user
state: absent
# ========================= Teardown ======================================
- name: Mysql_user Lock user | Delete test database
community.mysql.mysql_db:
<<: *mysql_params
name: mysql_lock_user_test
state: absent