diff --git a/changelogs/fragments/369-mysql_user_add_tls_requires.yml b/changelogs/fragments/369-mysql_user_add_tls_requires.yml new file mode 100644 index 00000000..10d39775 --- /dev/null +++ b/changelogs/fragments/369-mysql_user_add_tls_requires.yml @@ -0,0 +1,2 @@ +minor_changes: + - mysql_user - add TLS REQUIRES parameters (https://github.com/ansible-collections/community.mysql/pull/9). diff --git a/plugins/modules/mysql_user.py b/plugins/modules/mysql_user.py index b7193bba..9b1aca86 100644 --- a/plugins/modules/mysql_user.py +++ b/plugins/modules/mysql_user.py @@ -60,6 +60,14 @@ user instead of overwriting existing ones. type: bool default: no + tls_requires: + description: + - Set requirement for secure transport as a dictionary of requirements (see the examples). + - Valid requirements are SSL, X509, SUBJECT, ISSUER, CIPHER. + - SUBJECT, ISSUER and CIPHER are complementary, and mutually exclusive with SSL and X509. + - U(https://mariadb.com/kb/en/securing-connections-for-client-and-server/#requiring-tls). + type: dict + version_added: 1.0.0 sql_log_bin: description: - Whether binary logging should be enabled or disabled for the connection. @@ -180,6 +188,7 @@ 'db2.*': 'ALL,GRANT' # Note that REQUIRESSL is a special privilege that should only apply to *.* by itself. +# Setting this privilege in this manner is supported for backwards compatibility only. Use 'tls_requires' instead. - name: Modify user to require SSL connections. community.mysql.mysql_user: name: bob @@ -187,6 +196,20 @@ priv: '*.*:REQUIRESSL' state: present +- name: Modify user to require TLS connection with a valid client certificate + community.mysql.mysql_user: + name: bob + tls_requires: + x509: + state: present + +- name: Modify user to require TLS connection with a specific client certificate and cipher + community.mysql.mysql_user: + name: bob + tls_requires: + subject: '/CN=alice/O=MyDom, Inc./C=US/ST=Oregon/L=Portland' + cipher: 'ECDHE-ECDSA-AES256-SHA384' + - name: Ensure no user named 'sally'@'localhost' exists, also passing in the auth credentials. community.mysql.mysql_user: login_user: root @@ -358,8 +381,73 @@ def user_exists(cursor, user, host, host_all): return count[0] > 0 +def sanitize_requires(tls_requires): + sanitized_requires = {} + if tls_requires: + for key in tls_requires.keys(): + sanitized_requires[key.upper()] = tls_requires[key] + if any([key in ["CIPHER", "ISSUER", "SUBJECT"] for key in sanitized_requires.keys()]): + sanitized_requires.pop("SSL", None) + sanitized_requires.pop("X509", None) + return sanitized_requires + + if "X509" in sanitized_requires.keys(): + sanitized_requires = "X509" + else: + sanitized_requires = "SSL" + + return sanitized_requires + return None + + +def mogrify_requires(query, params, tls_requires): + if tls_requires: + if isinstance(tls_requires, dict): + k, v = zip(*tls_requires.items()) + requires_query = " AND ".join(("%s %%s" % key for key in k)) + params += v + else: + requires_query = tls_requires + query = " REQUIRE ".join((query, requires_query)) + return query, params + + +def do_not_mogrify_requires(query, params, tls_requires): + return query, params + + +def get_tls_requires(cursor, user, host): + if user: + if not use_old_user_mgmt(cursor): + query = "SHOW CREATE USER '%s'@'%s'" % (user, host) + else: + query = "SHOW GRANTS for '%s'@'%s'" % (user, host) + + cursor.execute(query) + require_list = list(filter(lambda x: "REQUIRE" in x, cursor.fetchall())) + require_line = require_list[0] if require_list else "" + pattern = r"(?<=\bREQUIRE\b)(.*?)(?=(?:\bPASSWORD\b|$))" + requires_match = re.search(pattern, require_line) + requires = requires_match.group().strip() if requires_match else "" + if len(requires.split()) > 1: + import shlex + + items = iter(shlex.split(requires)) + requires = dict(zip(items, items)) + return requires or None + + +def get_grants(cursor, user, host): + cursor.execute("SHOW GRANTS FOR %s@%s", (user, host)) + grants_line = list(filter(lambda x: "ON *.*" in x[0], cursor.fetchall()))[0] + pattern = r"(?<=\bGRANT\b)(.*?)(?=(?:\bON\b))" + grants = re.search(pattern, grants_line[0]).group().strip() + return grants.split(", ") + + def user_add(cursor, user, host, host_all, password, encrypted, - plugin, plugin_hash_string, plugin_auth_string, new_priv, check_mode): + plugin, plugin_hash_string, plugin_auth_string, new_priv, + tls_requires, check_mode): # we cannot create users without a proper hostname if host_all: return False @@ -370,27 +458,44 @@ def user_add(cursor, user, host, host_all, password, encrypted, # Determine what user management method server uses old_user_mgmt = use_old_user_mgmt(cursor) + mogrify = do_not_mogrify_requires if old_user_mgmt else mogrify_requires + if password and encrypted: - cursor.execute("CREATE USER %s@%s IDENTIFIED BY PASSWORD %s", (user, host, password)) + cursor.execute(*mogrify("CREATE USER %s@%s IDENTIFIED BY PASSWORD %s", (user, host, password), tls_requires)) elif password and not encrypted: if old_user_mgmt: - cursor.execute("CREATE USER %s@%s IDENTIFIED BY %s", (user, host, password)) + cursor.execute(*mogrify("CREATE USER %s@%s IDENTIFIED BY %s", (user, host, password), tls_requires)) else: cursor.execute("SELECT CONCAT('*', UCASE(SHA1(UNHEX(SHA1(%s)))))", (password,)) encrypted_password = cursor.fetchone()[0] - cursor.execute("CREATE USER %s@%s IDENTIFIED WITH mysql_native_password AS %s", (user, host, encrypted_password)) - + cursor.execute( + *mogrify( + "CREATE USER %s@%s IDENTIFIED WITH mysql_native_password AS %s", + (user, host, encrypted_password), + tls_requires, + ) + ) elif plugin and plugin_hash_string: - cursor.execute("CREATE USER %s@%s IDENTIFIED WITH %s AS %s", (user, host, plugin, plugin_hash_string)) + cursor.execute( + *mogrify( + "CREATE USER %s@%s IDENTIFIED WITH %s AS %s", (user, host, plugin, plugin_hash_string), tls_requires + ) + ) elif plugin and plugin_auth_string: - cursor.execute("CREATE USER %s@%s IDENTIFIED WITH %s BY %s", (user, host, plugin, plugin_auth_string)) + cursor.execute( + *mogrify( + "CREATE USER %s@%s IDENTIFIED WITH %s BY %s", (user, host, plugin, plugin_auth_string), tls_requires + ) + ) elif plugin: - cursor.execute("CREATE USER %s@%s IDENTIFIED WITH %s", (user, host, plugin)) + cursor.execute(*mogrify("CREATE USER %s@%s IDENTIFIED WITH %s", (user, host, plugin), tls_requires)) else: - cursor.execute("CREATE USER %s@%s", (user, host)) + cursor.execute(*mogrify("CREATE USER %s@%s", (user, host), tls_requires)) if new_priv is not None: for db_table, priv in iteritems(new_priv): - privileges_grant(cursor, user, host, db_table, priv) + 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 @@ -403,7 +508,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, module): + plugin, plugin_hash_string, plugin_auth_string, new_priv, + append_privs, tls_requires, module): changed = False msg = "User unchanged" grant_option = False @@ -537,7 +643,7 @@ def user_mod(cursor, user, host, host_all, password, encrypted, msg = "New privileges granted" if module.check_mode: return (True, msg) - privileges_grant(cursor, user, host, db_table, priv) + privileges_grant(cursor, user, host, db_table, priv, tls_requires) changed = True # If the db.table specification exists in both the user's current privileges @@ -551,9 +657,28 @@ def user_mod(cursor, user, host, host_all, password, encrypted, return (True, msg) if not append_privs: privileges_revoke(cursor, user, host, db_table, curr_priv[db_table], grant_option) - privileges_grant(cursor, user, host, db_table, new_priv[db_table]) + privileges_grant(cursor, user, host, db_table, new_priv[db_table], tls_requires) changed = True + # 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 (True, msg) + if not old_user_mgmt: + pre_query = "ALTER USER" + else: + pre_query = "GRANT %s ON *.* TO" % ",".join(get_grants(cursor, user, host)) + + if tls_requires is not None: + query = " ".join((pre_query, "%s@%s")) + cursor.execute(*mogrify_requires(query, (user, host), tls_requires)) + else: + query = " ".join(pre_query, "%s@%s REQUIRE NONE") + cursor.execute(query, (user, host)) + changed = True + return (changed, msg) @@ -611,7 +736,7 @@ def pick(x): privileges = [pick(x.strip()) for x in privileges] if "WITH GRANT OPTION" in res.group(7): privileges.append('GRANT') - if "REQUIRE SSL" in res.group(7): + if 'REQUIRE SSL' in res.group(7): privileges.append('REQUIRESSL') db = res.group(2) output.setdefault(db, []).extend(privileges) @@ -690,19 +815,23 @@ def privileges_revoke(cursor, user, host, db_table, priv, grant_option): cursor.execute(query, (user, host)) -def privileges_grant(cursor, user, host, db_table, priv): +def privileges_grant(cursor, user, host, db_table, priv, tls_requires): # Escape '%' since mysql db.execute uses a format string and the # specification of db and table often use a % (SQL wildcard) db_table = db_table.replace('%', '%%') priv_string = ",".join([p for p in priv if p not in ('GRANT', 'REQUIRESSL')]) query = ["GRANT %s ON %s" % (priv_string, db_table)] query.append("TO %s@%s") - if 'REQUIRESSL' in priv: + params = (user, host) + if tls_requires and use_old_user_mgmt(cursor): + query, params = mogrify_requires(" ".join(query), params, tls_requires) + query = [query] + if 'REQUIRESSL' in priv and not tls_requires: query.append("REQUIRE SSL") if 'GRANT' in priv: query.append("WITH GRANT OPTION") query = ' '.join(query) - cursor.execute(query, (user, host)) + cursor.execute(query, params) def convert_priv_dict_to_str(priv): @@ -850,6 +979,7 @@ def limit_resources(module, cursor, user, host, resource_limits, check_mode): cursor.execute(query, (user, host)) return True + # =========================================== # Module execution. # @@ -870,6 +1000,7 @@ def main(): host_all=dict(type="bool", default=False), state=dict(type='str', default='present', choices=['absent', 'present']), priv=dict(type='raw'), + tls_requires=dict(type='dict'), append_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), @@ -895,9 +1026,10 @@ def main(): host_all = module.params["host_all"] state = module.params["state"] priv = module.params["priv"] - check_implicit_admin = module.params['check_implicit_admin'] - connect_timeout = module.params['connect_timeout'] - config_file = module.params['config_file'] + tls_requires = sanitize_requires(module.params["tls_requires"]) + check_implicit_admin = module.params["check_implicit_admin"] + connect_timeout = module.params["connect_timeout"] + config_file = module.params["config_file"] append_privs = module.boolean(module.params["append_privs"]) update_password = module.params['update_password'] ssl_cert = module.params["client_cert"] @@ -922,7 +1054,7 @@ def main(): try: if check_implicit_admin: try: - cursor, db_conn = mysql_connect(module, 'root', '', config_file, ssl_cert, ssl_key, ssl_ca, db, + cursor, db_conn = mysql_connect(module, "root", "", config_file, ssl_cert, ssl_key, ssl_ca, db, connect_timeout=connect_timeout) except Exception: pass @@ -950,14 +1082,14 @@ def main(): if state == "present": if user_exists(cursor, user, host, host_all): try: - if update_password == 'always': + 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, module) + priv, append_privs, tls_requires, module) else: changed, msg = user_mod(cursor, user, host, host_all, None, encrypted, plugin, plugin_hash_string, plugin_auth_string, - priv, append_privs, module) + priv, append_privs, tls_requires, module) except (SQLParseError, InvalidPrivsError, mysql_driver.Error) as e: module.fail_json(msg=to_native(e)) @@ -967,7 +1099,7 @@ def main(): try: changed = user_add(cursor, user, host, host_all, password, encrypted, plugin, plugin_hash_string, plugin_auth_string, - priv, module.check_mode) + priv, tls_requires, module.check_mode) if changed: msg = "User added" diff --git a/tests/integration/targets/test_mysql_user/tasks/main.yml b/tests/integration/targets/test_mysql_user/tasks/main.yml index fcfa3b59..0b06ace4 100644 --- a/tests/integration/targets/test_mysql_user/tasks/main.yml +++ b/tests/integration/targets/test_mysql_user/tasks/main.yml @@ -129,6 +129,165 @@ - include: assert_no_user.yml user_name={{user_name_2}} + # ============================================================ + # Create users with TLS requirements and verify requirements are assigned + # + - name: find out the database version + mysql_info: + <<: *mysql_params + filter: version + register: db_version + + - name: create user with TLS requirements in check mode (expect changed=true) + mysql_user: + <<: *mysql_params + name: "{{ user_name_1 }}" + password: "{{ user_password_1 }}" + tls_requires: + SSL: + check_mode: yes + register: result + + - name: Assert check mode user create reports changed state + assert: + that: + - result is changed + + - include: assert_no_user.yml user_name={{user_name_1}} + + - name: create user with TLS requirements state=present (expect changed=true) + mysql_user: + <<: *mysql_params + name: '{{ item[0] }}' + password: '{{ user_password_1 }}' + tls_requires: '{{ item[1] }}' + with_together: + - [ '{{ user_name_1 }}', '{{ user_name_2 }}', '{{ user_name_3 }}'] + - + - SSL: + - X509: + - subject: '/CN=alice/O=MyDom, Inc./C=US/ST=Oregon/L=Portland' + cipher: 'ECDHE-ECDSA-AES256-SHA384' + issuer: '/CN=org/O=MyDom, Inc./C=US/ST=Oregon/L=Portland' + + - block: + - name: retrieve TLS requiremets for users in old database version + command: "{{ mysql_command }} -L -N -s -e \"SHOW GRANTS for '{{ item }}'@'localhost'\"" + register: old_result + with_items: ['{{ user_name_1 }}', '{{ user_name_2 }}', '{{ user_name_3 }}'] + + - name: set old database separator + set_fact: + separator: '\n' + # Semantically: when mysql version <= 5.6 or MariaDB version <= 10.1 + when: db_version.version.major <= 5 and db_version.version.minor <= 6 or db_version.version.major == 10 and db_version.version.minor < 2 + + - block: + - name: retrieve TLS requiremets for users in new database version + command: "{{ mysql_command }} -L -N -s -e \"SHOW CREATE USER '{{ item }}'@'localhost'\"" + register: new_result + with_items: ['{{ user_name_1 }}', '{{ user_name_2 }}', '{{ user_name_3 }}'] + + - name: set new database separator + set_fact: + separator: 'PASSWORD' + # Semantically: when mysql version >= 5.7 or MariaDB version >= 10.2 + when: db_version.version.major == 5 and db_version.version.minor >= 7 or db_version.version.major > 5 and db_version.version.major < 10 or db_version.version.major == 10 and db_version.version.minor >= 2 + + - block: + - name: assert user1 TLS requirements + assert: + that: + - "'SSL' in reqs" + vars: + - reqs: "{{((old_result.results[0] is skipped | ternary(new_result, old_result)).results | selectattr('item', 'contains', user_name_1) | first).stdout.split('REQUIRE')[1].split(separator)[0].strip()}}" + + - name: assert user2 TLS requirements + assert: + that: + - "'X509' in reqs" + vars: + - reqs: "{{((old_result.results[0] is skipped | ternary(new_result, old_result)).results | selectattr('item', 'contains', user_name_2) | first).stdout.split('REQUIRE')[1].split(separator)[0].strip()}}" + + - name: assert user3 TLS requirements + assert: + that: + - "'/CN=alice/O=MyDom, Inc./C=US/ST=Oregon/L=Portland' in (reqs | select('contains', 'SUBJECT') | first)" + - "'/CN=org/O=MyDom, Inc./C=US/ST=Oregon/L=Portland' in (reqs | select('contains', 'ISSUER') | first)" + - "'ECDHE-ECDSA-AES256-SHA384' in (reqs | select('contains', 'CIPHER') | first)" + vars: + - reqs: "{{((old_result.results[0] is skipped | ternary(new_result, old_result)).results | selectattr('item', 'contains', user_name_3) | first).stdout.split('REQUIRE')[1].split(separator)[0].replace(\"' \", \"':\").split(\":\")}}" + # CentOS 6 uses an older version of jinja that does not provide the selectattr filter. + when: ansible_distribution != 'CentOS' or ansible_distribution_major_version != '6' + + - name: modify user with TLS requirements state=present in check mode (expect changed=true) + mysql_user: + <<: *mysql_params + name: '{{ user_name_1 }}' + password: '{{ user_password_1 }}' + tls_requires: + X509: + check_mode: yes + register: result + + - name: Assert check mode user update reports changed state + assert: + that: + - result is changed + + - name: retrieve TLS requiremets for users in old database version + command: "{{ mysql_command }} -L -N -s -e \"SHOW GRANTS for '{{ user_name_1 }}'@'localhost'\"" + register: old_result + when: db_version.version.major <= 5 and db_version.version.minor <= 6 or db_version.version.major == 10 and db_version.version.minor < 2 + + - name: retrieve TLS requiremets for users in new database version + command: "{{ mysql_command }} -L -N -s -e \"SHOW CREATE USER '{{ user_name_1 }}'@'localhost'\"" + register: new_result + when: db_version.version.major == 5 and db_version.version.minor >= 7 or db_version.version.major > 5 and db_version.version.major < 10 or db_version.version.major == 10 and db_version.version.minor >= 2 + + - name: assert user1 TLS requirements was not changed + assert: + that: "'SSL' in reqs" + vars: + - reqs: "{{(old_result is skipped | ternary(new_result, old_result)).stdout.split('REQUIRE')[1].split(separator)[0].strip()}}" + + - name: modify user with TLS requirements state=present (expect changed=true) + mysql_user: + <<: *mysql_params + name: '{{ user_name_1 }}' + password: '{{ user_password_1 }}' + tls_requires: + X509: + + - name: retrieve TLS requiremets for users in old database version + command: "{{ mysql_command }} -L -N -s -e \"SHOW GRANTS for '{{ user_name_1 }}'@'localhost'\"" + register: old_result + when: db_version.version.major <= 5 and db_version.version.minor <= 6 or db_version.version.major == 10 and db_version.version.minor < 2 + + - name: retrieve TLS requiremets for users in new database version + command: "{{ mysql_command }} -L -N -s -e \"SHOW CREATE USER '{{ user_name_1 }}'@'localhost'\"" + register: new_result + when: db_version.version.major == 5 and db_version.version.minor >= 7 or db_version.version.major > 5 and db_version.version.major < 10 or db_version.version.major == 10 and db_version.version.minor >= 2 + + - name: assert user1 TLS requirements + assert: + that: "'X509' in reqs" + vars: + - reqs: "{{(old_result is skipped | ternary(new_result, old_result)).stdout.split('REQUIRE')[1].split(separator)[0].strip()}}" + + + - include: remove_user.yml user_name={{user_name_1}} user_password={{ user_password_1 }} + + - include: remove_user.yml user_name={{user_name_2}} user_password={{ user_password_1 }} + + - include: remove_user.yml user_name={{user_name_3}} user_password={{ user_password_1 }} + + - include: assert_no_user.yml user_name={{user_name_1}} + + - include: assert_no_user.yml user_name={{user_name_2}} + + - include: assert_no_user.yml user_name={{user_name_3}} + # ============================================================ # Assert user has access to multiple databases #