diff --git a/changelogs/fragments/598-password_expire-support-for-mysql_user.yml b/changelogs/fragments/598-password_expire-support-for-mysql_user.yml new file mode 100644 index 000000000..c0fd472f6 --- /dev/null +++ b/changelogs/fragments/598-password_expire-support-for-mysql_user.yml @@ -0,0 +1,2 @@ +minor_changes: + - "mysql_user - add the ``password_expire`` and ``password_expire_interval`` arguments to implement the password expiration management for mysql user (https://github.com/ansible-collections/community.mysql/pull/598)." diff --git a/plugins/module_utils/implementations/mariadb/user.py b/plugins/module_utils/implementations/mariadb/user.py index c1d2b6133..cdc14b217 100644 --- a/plugins/module_utils/implementations/mariadb/user.py +++ b/plugins/module_utils/implementations/mariadb/user.py @@ -23,3 +23,9 @@ def server_supports_alter_user(cursor): version = get_server_version(cursor) return LooseVersion(version) >= LooseVersion("10.2") + + +def server_supports_password_expire(cursor): + version = get_server_version(cursor) + + return LooseVersion(version) >= LooseVersion("10.4.3") diff --git a/plugins/module_utils/implementations/mysql/user.py b/plugins/module_utils/implementations/mysql/user.py index 1bdad5740..4e41c0542 100644 --- a/plugins/module_utils/implementations/mysql/user.py +++ b/plugins/module_utils/implementations/mysql/user.py @@ -24,3 +24,9 @@ def server_supports_alter_user(cursor): version = get_server_version(cursor) return LooseVersion(version) >= LooseVersion("5.6") + + +def server_supports_password_expire(cursor): + version = get_server_version(cursor) + + return LooseVersion(version) >= LooseVersion("5.7") diff --git a/plugins/module_utils/user.py b/plugins/module_utils/user.py index 1e5a275b8..17ad4b0c2 100644 --- a/plugins/module_utils/user.py +++ b/plugins/module_utils/user.py @@ -152,7 +152,8 @@ def get_existing_authentication(cursor, user, host): def user_add(cursor, user, host, host_all, password, encrypted, plugin, plugin_hash_string, plugin_auth_string, new_priv, - attributes, tls_requires, reuse_existing_password, module): + attributes, tls_requires, reuse_existing_password, module, + password_expire, password_expire_interval): # 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") @@ -205,6 +206,12 @@ def user_add(cursor, user, host, host_all, password, encrypted, query_with_args_and_tls_requires = query_with_args + (tls_requires,) cursor.execute(*mogrify(*query_with_args_and_tls_requires)) + if password_expire: + if not impl.server_supports_password_expire(cursor): + module.fail_json(msg="The server version does not match the requirements " + "for password_expire parameter. See module's documentation.") + set_password_expire(cursor, user, host, password_expire, password_expire_interval) + if new_priv is not None: for db_table, priv in iteritems(new_priv): privileges_grant(cursor, user, host, db_table, priv, tls_requires) @@ -230,7 +237,8 @@ def is_hash(password): def user_mod(cursor, user, host, host_all, password, encrypted, plugin, plugin_hash_string, plugin_auth_string, new_priv, - append_privs, subtract_privs, attributes, tls_requires, module, role=False, maria_role=False): + append_privs, subtract_privs, attributes, tls_requires, module, + password_expire, password_expire_interval, role=False, maria_role=False): changed = False msg = "User unchanged" grant_option = False @@ -312,6 +320,28 @@ def user_mod(cursor, user, host, host_all, password, encrypted, raise e changed = True + # Handle password expiration + if bool(password_expire): + if not impl.server_supports_password_expire(cursor): + module.fail_json(msg="The server version does not match the requirements " + "for password_expire parameter. See module's documentation.") + update = False + mariadb_role = True if "mariadb" in str(impl.__name__) else False + current_password_policy = get_password_expiration_policy(cursor, user, host, maria_role=mariadb_role) + password_expired = is_password_expired(cursor, user, host) + # Check if changes needed to be applied. + if not ((current_password_policy == -1 and password_expire == "default") or + (current_password_policy == 0 and password_expire == "never") or + (current_password_policy == password_expire_interval and password_expire == "interval") or + (password_expire == 'now' and password_expired)): + + update = True + + if not module.check_mode: + set_password_expire(cursor, user, host, password_expire, password_expire_interval) + password_changed = True + changed = True + # Handle plugin authentication if plugin and not role: cursor.execute("SELECT plugin, authentication_string FROM mysql.user " @@ -973,6 +1003,72 @@ def limit_resources(module, cursor, user, host, resource_limits, check_mode): return True +def set_password_expire(cursor, user, host, password_expire, password_expire_interval): + """Fuction to set passowrd expiration for user. + + Args: + cursor (cursor): DB driver cursor object. + user (str): User name. + host (str): User hostname. + password_expire (str): Password expiration mode. + password_expire_days (int): Invterval of days password expires. + """ + if password_expire.lower() == "never": + statement = "PASSWORD EXPIRE NEVER" + elif password_expire.lower() == "default": + statement = "PASSWORD EXPIRE DEFAULT" + elif password_expire.lower() == "interval": + statement = "PASSWORD EXPIRE INTERVAL %d DAY" % (password_expire_interval) + elif password_expire.lower() == "now": + statement = "PASSWORD EXPIRE" + + cursor.execute("ALTER USER %s@%s " + statement, (user, host)) + + +def get_password_expiration_policy(cursor, user, host, maria_role=False): + """Function to get password policy for user. + + Args: + cursor (cursor): DB driver cursor object. + user (str): User name. + host (str): User hostname. + maria_role (bool, optional): mariadb or mysql. Defaults to False. + + Returns: + policy (int): Current users password policy. + """ + if not maria_role: + statement = "SELECT IFNULL(password_lifetime, -1) FROM mysql.user \ + WHERE User = %s AND Host = %s", (user, host) + else: + statement = "SELECT JSON_EXTRACT(Priv, '$.password_lifetime') AS password_lifetime \ + FROM mysql.global_priv \ + WHERE User = %s AND Host = %s", (user, host) + cursor.execute(*statement) + policy = cursor.fetchone()[0] + return int(policy) + + +def is_password_expired(cursor, user, host): + """Function to check if password is expired + + Args: + cursor (cursor): DB driver cursor object. + user (str): User name. + host (str): User hostname. + + Returns: + expired (bool): True if expired, else False. + """ + statement = "SELECT password_expired FROM mysql.user \ + WHERE User = %s AND Host = %s", (user, host) + cursor.execute(*statement) + expired = cursor.fetchone()[0] + if str(expired) == "Y": + return True + return False + + def get_attribute_support(cursor): """Checks if the MySQL server supports user attributes. diff --git a/plugins/modules/mysql_role.py b/plugins/modules/mysql_role.py index 571379134..3e3462ab1 100644 --- a/plugins/modules/mysql_role.py +++ b/plugins/modules/mysql_role.py @@ -932,7 +932,8 @@ def update(self, users, privs, check_mode=False, result = user_mod(self.cursor, self.name, self.host, None, None, None, None, None, None, privs, append_privs, subtract_privs, None, None, - self.module, role=True, maria_role=self.is_mariadb) + self.module, None, None, role=True, + maria_role=self.is_mariadb) changed = result['changed'] if admin: diff --git a/plugins/modules/mysql_user.py b/plugins/modules/mysql_user.py index c6a02fc17..e02b15326 100644 --- a/plugins/modules/mysql_user.py +++ b/plugins/modules/mysql_user.py @@ -155,6 +155,21 @@ - Cannot be used to set global variables, use the M(community.mysql.mysql_variables) module instead. type: dict version_added: '3.6.0' + password_expire: + description: + - C(never) - I(password) will never expire. + - C(default) - I(password) is defined using global system variable I(default_password_lifetime) setting. + - C(interval) - I(password) will expire in days which is defined in I(password_expire_interval). + - C(now) - I(password) will expire immediately. + type: str + choices: [ now, never, default, interval ] + version_added: '3.9.0' + password_expire_interval: + description: + - Number of days I(password) will expire. Requires I(password_expire=interval). + type: int + version_added: '3.9.0' + column_case_sensitive: description: - The default is C(false). @@ -429,6 +444,8 @@ def main(): force_context=dict(type='bool', default=False), session_vars=dict(type='dict'), 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), ) module = AnsibleModule( argument_spec=argument_spec, @@ -466,6 +483,8 @@ def main(): resource_limits = module.params["resource_limits"] session_vars = module.params["session_vars"] column_case_sensitive = module.params["column_case_sensitive"] + password_expire = module.params["password_expire"] + password_expire_interval = module.params["password_expire_interval"] 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)) @@ -476,6 +495,10 @@ def main(): if mysql_driver is None: module.fail_json(msg=mysql_driver_fail_msg) + if password_expire_interval and password_expire_interval < 1: + module.fail_json(msg="password_expire_interval value \ + should be positive number") + cursor = None try: if check_implicit_admin: @@ -522,12 +545,14 @@ def main(): 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, attributes, tls_requires, module) + priv, append_privs, subtract_privs, attributes, tls_requires, module, + password_expire, password_expire_interval) else: result = user_mod(cursor, user, host, host_all, None, encrypted, None, None, None, - priv, append_privs, subtract_privs, attributes, tls_requires, module) + priv, append_privs, subtract_privs, attributes, tls_requires, module, + password_expire, password_expire_interval) changed = result['changed'] msg = result['msg'] password_changed = result['password_changed'] @@ -544,7 +569,8 @@ 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, attributes, tls_requires, reuse_existing_password, module) + priv, attributes, tls_requires, reuse_existing_password, module, + password_expire, password_expire_interval) changed = result['changed'] password_changed = result['password_changed'] final_attributes = result['attributes'] diff --git a/tests/integration/targets/test_mysql_user/tasks/main.yml b/tests/integration/targets/test_mysql_user/tasks/main.yml index f5e074845..8ec0798c5 100644 --- a/tests/integration/targets/test_mysql_user/tasks/main.yml +++ b/tests/integration/targets/test_mysql_user/tasks/main.yml @@ -43,6 +43,8 @@ - include_tasks: test_idempotency.yml + - include_tasks: test_password_expire.yml + # ============================================================ # Create user with no privileges and verify default privileges are assign # diff --git a/tests/integration/targets/test_mysql_user/tasks/test_password_expire.yml b/tests/integration/targets/test_mysql_user/tasks/test_password_expire.yml new file mode 100644 index 000000000..7e70ece0e --- /dev/null +++ b/tests/integration/targets/test_mysql_user/tasks/test_password_expire.yml @@ -0,0 +1,174 @@ +--- +# Tests scenarios for password_expire + +- vars: + mysql_parameters: &mysql_params + login_user: "{{ mysql_user }}" + login_password: "{{ mysql_password }}" + login_host: "{{ mysql_host }}" + login_port: "{{ mysql_primary_port }}" + + block: + - include_tasks: utils/assert_user_password_expire.yml + vars: + username: "{{ item.username }}" + host: "{{ item.host | default('localhost')}}" + password_expire: "{{ item.password_expire }}" + password: "{{ user_password_1 }}" + expect_change: "{{ item.expect_change }}" + expect_password_expire_change: "{{ item.expect_password_expire_change }}" + expected_password_lifetime: "{{ item.expected_password_lifetime }}" + password_expire_interval: "{{ item.password_expire_interval | default(omit) }}" + expected_password_expired: "{{ item.expected_password_expired }}" + check_mode: "{{ item.check_mode | default(omit) }}" + loop: + # all variants set the password when nothing exists + # never expires + - username: "{{ user_name_1 }}" + host: "%" + password_expire: never + expect_change: true + expected_password_lifetime: "0" + expected_password_expired: "N" + # expires ussing default policy + - username: "{{ user_name_2 }}" + password_expire: default + expect_change: true + expected_password_lifetime: "-1" + expected_password_expired: "N" + # expires ussing interval + - username: "{{ user_name_3 }}" + password_expire: interval + password_expire_interval: "10" + expect_change: true + expected_password_lifetime: "10" + expected_password_expired: "N" + + # assert idempotency + - username: "{{ user_name_1 }}" + host: "%" + password_expire: never + expect_change: false + expected_password_lifetime: "0" + expected_password_expired: "N" + - username: "{{ user_name_2 }}" + password_expire: default + expect_change: false + expected_password_lifetime: "-1" + expected_password_expired: "N" + - username: "{{ user_name_3 }}" + password_expire: interval + password_expire_interval: "10" + expect_change: false + expected_password_lifetime: "10" + expected_password_expired: "N" + + # assert change is made + - username: "{{ user_name_3 }}" + password_expire: never + expect_change: true + expected_password_lifetime: "0" + expected_password_expired: "N" + - username: "{{ user_name_1 }}" + host: "%" + password_expire: default + expect_change: true + expected_password_lifetime: "-1" + expected_password_expired: "N" + - username: "{{ user_name_2 }}" + password_expire: interval + password_expire_interval: "100" + expect_change: true + expected_password_lifetime: "100" + expected_password_expired: "N" + + # assert password expires now + - username: "{{ user_name_1 }}" + host: "%" + password_expire: now + expect_change: true + expected_password_lifetime: "-1" # password lifetime should be the same + expected_password_expired: "Y" + - username: "{{ user_name_2 }}" + password_expire: now + expect_change: true + expected_password_lifetime: "100" # password lifetime should be the same + expected_password_expired: "Y" + + # assert idempotency password expires now + - username: "{{ user_name_1 }}" + host: "%" + password_expire: now + expect_change: false + expected_password_lifetime: "-1" # password lifetime should be the same + expected_password_expired: "Y" + - username: "{{ user_name_2 }}" + password_expire: now + expect_change: false + expected_password_lifetime: "100" # password lifetime should be the same + expected_password_expired: "Y" + + # assert check_mode + - username: "{{ user_name_3 }}" + password_expire: interval + password_expire_interval: 10 + check_mode: true + expect_change: false + expected_password_lifetime: "0" + expected_password_expired: "N" + + - name: password_expire | Set password_expire = interval without password_expire_interval + community.mysql.mysql_user: + <<: *mysql_params + name: '{{ user_name_4 }}' + host: '%' + password: '{{ user_password_4 }}' + password_expire: interval + state: present + register: result + ignore_errors: true + + - name: password_expire | Assert that action fails if 'password_expire_interval' not set + ansible.builtin.assert: + that: + - result is failed + + - name: password_expire | Set password_expire_interval < 1 + community.mysql.mysql_user: + <<: *mysql_params + name: '{{ user_name_4 }}' + host: '%' + password: '{{ user_password_4 }}' + password_expire: interval + password_expire_interval: -1 + state: present + register: result + ignore_errors: true + + - name: password_expire | Assert that action fails if 'password_expire_interval' is < 1 + ansible.builtin.assert: + that: + - result is failed + - "'should be positive number' in result.msg" + + - name: password_expire | check mode for user creation + community.mysql.mysql_user: + <<: *mysql_params + name: '{{ user_name_4 }}' + host: '%' + password: '{{ user_password_4 }}' + password_expire: interval + password_expire_interval: 20 + state: present + register: result + check_mode: True + failed_when: result is changed + + - include_tasks: utils/remove_user.yml + vars: + user_name: "{{ item.username }}" + loop: + - username: "{{ user_name_1 }}" + - username: "{{ user_name_2 }}" + - username: "{{ user_name_3 }}" + - username: "{{ user_name_4 }}" diff --git a/tests/integration/targets/test_mysql_user/tasks/utils/assert_user_password_expire.yml b/tests/integration/targets/test_mysql_user/tasks/utils/assert_user_password_expire.yml new file mode 100644 index 000000000..3798802ea --- /dev/null +++ b/tests/integration/targets/test_mysql_user/tasks/utils/assert_user_password_expire.yml @@ -0,0 +1,56 @@ +--- +- name: Utils | Assert user password_expire | Create modify {{ username }} with password_expire + community.mysql.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 }}" + password_expire: "{{ password_expire }}" + password_expire_interval: "{{ password_expire_interval | default(omit) }}" + register: result + check_mode: "{{ check_mode | default(false) }}" + failed_when: result.changed != expect_change_value + vars: + expect_change_value: "{{ expect_change }}" + +- name: Utils | Assert user password_lifetime | Query user '{{ username }}' + ansible.builtin.command: + cmd: > + {{ mysql_command }} -BNe "SELECT IFNULL(password_lifetime, -1) + FROM mysql.user where user='{{ username }}' and host='{{ host }}'" + register: password_lifetime + when: + - db_engine == 'mysql' + - db_version is version('5.7.0', '>=') + failed_when: expected_password_lifetime_value not in password_lifetime.stdout_lines + vars: + expected_password_lifetime_value: "{{ expected_password_lifetime }}" + +- name: Utils | Assert user password_lifetime | Query user '{{ username }}' + ansible.builtin.command: + "{{ mysql_command }} -BNe \"SELECT JSON_EXTRACT(Priv, '$.password_lifetime') AS password_lifetime \ + FROM mysql.global_priv \ + WHERE user='{{ username }}' and host='{{ host }}'\"" + register: password_lifetime + when: + - db_engine == 'mariadb' + - db_version is version('10.4.3', '>=') + failed_when: expected_password_lifetime_value not in password_lifetime.stdout_lines + vars: + expected_password_lifetime_value: "{{ expected_password_lifetime }}" + +- name: Utils | Assert user password_expired | Query user '{{ username }}' + ansible.builtin.command: + cmd: > + {{ mysql_command }} -BNe "SELECT password_expired FROM mysql.user + WHERE user='{{ username }}' and host='{{ host }}'" + register: password_expired + when: (db_engine == 'mysql' and db_version is version('5.7.0', '>=')) or + (db_engine == 'mariadb' and db_version is version('10.4.3', '>=')) + failed_when: expected_password_expired_value not in password_expired.stdout_lines + vars: + expected_password_expired_value: "{{ expected_password_expired }}"