From d7beeec410ce85bd06cb2683fdcf4cac31311427 Mon Sep 17 00:00:00 2001 From: Laurent Indermuehle Date: Mon, 11 Sep 2023 19:28:26 +0200 Subject: [PATCH] WIP attempt to retrieve all users privileges --- Makefile | 88 ++-- plugins/module_utils/user.py | 12 +- plugins/modules/mysql_info.py | 42 +- plugins/modules/mysql_user.py | 2 +- .../tasks/filter_users_privs.yml | 13 + .../targets/test_mysql_info/tasks/main.yml | 388 +++++++++--------- 6 files changed, 307 insertions(+), 238 deletions(-) create mode 100644 tests/integration/targets/test_mysql_info/tasks/filter_users_privs.yml diff --git a/Makefile b/Makefile index 7ea0785..7bc73cc 100644 --- a/Makefile +++ b/Makefile @@ -50,55 +50,63 @@ test-integration: --health-cmd 'mysqladmin ping -P 3306 -pmsandbox | grep alive || exit 1' \ docker.io/library/$(db_engine_name):$(db_engine_version) \ mysqld - podman run \ - --detach \ - --replace \ - --name replica1 \ - --env MARIADB_ROOT_PASSWORD=msandbox \ - --env MYSQL_ROOT_PASSWORD=msandbox \ - --network podman \ - --publish 3308:3306 \ - --health-cmd 'mysqladmin ping -P 3306 -pmsandbox | grep alive || exit 1' \ - docker.io/library/$(db_engine_name):$(db_engine_version) \ - mysqld - podman run \ - --detach \ - --replace \ - --name replica2 \ - --env MARIADB_ROOT_PASSWORD=msandbox \ - --env MYSQL_ROOT_PASSWORD=msandbox \ - --network podman \ - --publish 3309:3306 \ - --health-cmd 'mysqladmin ping -P 3306 -pmsandbox | grep alive || exit 1' \ - docker.io/library/$(db_engine_name):$(db_engine_version) \ - mysqld - # Setup replication and restart containers - podman exec primary bash -c 'echo -e [mysqld]\\nserver-id=1\\nlog-bin=/var/lib/mysql/primary-bin > /etc/mysql/conf.d/replication.cnf' - podman exec replica1 bash -c 'echo -e [mysqld]\\nserver-id=2\\nlog-bin=/var/lib/mysql/replica1-bin > /etc/mysql/conf.d/replication.cnf' - podman exec replica2 bash -c 'echo -e [mysqld]\\nserver-id=3\\nlog-bin=/var/lib/mysql/replica2-bin > /etc/mysql/conf.d/replication.cnf' - # Don't restart a container unless it is healthy - while ! podman healthcheck run primary && [[ "$$SECONDS" -lt 120 ]]; do sleep 1; done - podman restart -t 30 primary - while ! podman healthcheck run replica1 && [[ "$$SECONDS" -lt 120 ]]; do sleep 1; done - podman restart -t 30 replica1 - while ! podman healthcheck run replica2 && [[ "$$SECONDS" -lt 120 ]]; do sleep 1; done - podman restart -t 30 replica2 - while ! podman healthcheck run primary && [[ "$$SECONDS" -lt 120 ]]; do sleep 1; done - mkdir -p .venv/$(ansible) - python$(local_python_version) -m venv .venv/$(ansible) +# podman run \ +# --detach \ +# --replace \ +# --name replica1 \ +# --env MARIADB_ROOT_PASSWORD=msandbox \ +# --env MYSQL_ROOT_PASSWORD=msandbox \ +# --network podman \ +# --publish 3308:3306 \ +# --health-cmd 'mysqladmin ping -P 3306 -pmsandbox | grep alive || exit 1' \ +# docker.io/library/$(db_engine_name):$(db_engine_version) \ +# mysqld +# podman run \ +# --detach \ +# --replace \ +# --name replica2 \ +# --env MARIADB_ROOT_PASSWORD=msandbox \ +# --env MYSQL_ROOT_PASSWORD=msandbox \ +# --network podman \ +# --publish 3309:3306 \ +# --health-cmd 'mysqladmin ping -P 3306 -pmsandbox | grep alive || exit 1' \ +# docker.io/library/$(db_engine_name):$(db_engine_version) \ +# mysqld +# # Setup replication and restart containers +# podman exec primary bash -c 'echo -e [mysqld]\\nserver-id=1\\nlog-bin=/var/lib/mysql/primary-bin > /etc/mysql/conf.d/replication.cnf' +# podman exec replica1 bash -c 'echo -e [mysqld]\\nserver-id=2\\nlog-bin=/var/lib/mysql/replica1-bin > /etc/mysql/conf.d/replication.cnf' +# podman exec replica2 bash -c 'echo -e [mysqld]\\nserver-id=3\\nlog-bin=/var/lib/mysql/replica2-bin > /etc/mysql/conf.d/replication.cnf' +# # Don't restart a container unless it is healthy +# while ! podman healthcheck run primary && [[ "$$SECONDS" -lt 120 ]]; do sleep 1; done +# podman restart -t 30 primary +# while ! podman healthcheck run replica1 && [[ "$$SECONDS" -lt 120 ]]; do sleep 1; done +# podman restart -t 30 replica1 +# while ! podman healthcheck run replica2 && [[ "$$SECONDS" -lt 120 ]]; do sleep 1; done +# podman restart -t 30 replica2 +# while ! podman healthcheck run primary && [[ "$$SECONDS" -lt 120 ]]; do sleep 1; done +# mkdir -p .venv/$(ansible) +# python$(local_python_version) -m venv .venv/$(ansible) # Start venv (use `; \` to keep the same shell) +# source .venv/$(ansible)/bin/activate; \ +# python$(local_python_version) -m ensurepip; \ +# python$(local_python_version) -m pip install --disable-pip-version-check \ +# https://github.com/ansible/ansible/archive/$(ansible).tar.gz; \ +# set -x; \ +# ansible-test integration $(target) -v --color --coverage --diff \ +# --docker ghcr.io/ansible-collections/community.mysql/test-container\ +# -$(db_client)-py$(python_version_flat)-$(connector_name)$(connector_version_flat):latest \ +# --docker-network podman $(_continue_on_errors) $(_keep_containers_alive) --python $(python); \ +# set +x + # End of venv + source .venv/$(ansible)/bin/activate; \ - python$(local_python_version) -m ensurepip; \ - python$(local_python_version) -m pip install --disable-pip-version-check \ - https://github.com/ansible/ansible/archive/$(ansible).tar.gz; \ set -x; \ ansible-test integration $(target) -v --color --coverage --diff \ --docker ghcr.io/ansible-collections/community.mysql/test-container\ -$(db_client)-py$(python_version_flat)-$(connector_name)$(connector_version_flat):latest \ --docker-network podman $(_continue_on_errors) $(_keep_containers_alive) --python $(python); \ set +x - # End of venv rm tests/integration/db_engine_name rm tests/integration/db_engine_version diff --git a/plugins/module_utils/user.py b/plugins/module_utils/user.py index e1d80ab..1434b3b 100644 --- a/plugins/module_utils/user.py +++ b/plugins/module_utils/user.py @@ -104,9 +104,13 @@ def get_tls_requires(cursor, user, host): return requires or None -def get_grants(cursor, user, host): +def get_grants(module, 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] + try: + grants_line = list(filter(lambda x: "ON *.*" in x[0], cursor.fetchall()))[0] + except Exception as e: + module.fail_json(msg="Error %s" % e) + pattern = r"(?<=\bGRANT\b)(.*?)(?=(?:\bON\b))" grants = re.search(pattern, grants_line[0]).group().strip() return grants.split(", ") @@ -132,7 +136,7 @@ def get_existing_authentication(cursor, user): return None -def user_add(cursor, user, host, host_all, password, encrypted, +def user_add(module, cursor, user, host, host_all, password, encrypted, plugin, plugin_hash_string, plugin_auth_string, new_priv, tls_requires, check_mode, reuse_existing_password): # we cannot create users without a proper hostname @@ -187,7 +191,7 @@ def user_add(cursor, user, host, host_all, password, encrypted, for db_table, priv in iteritems(new_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) + privileges_grant(cursor, user, host, "*.*", get_grants(module, cursor, user, host), tls_requires) return {'changed': True, 'password_changed': not used_existing_password} diff --git a/plugins/modules/mysql_info.py b/plugins/modules/mysql_info.py index cb9f029..d1c8291 100644 --- a/plugins/modules/mysql_info.py +++ b/plugins/modules/mysql_info.py @@ -19,7 +19,7 @@ options: description: - Limit the collected information by comma separated string or YAML list. - Allowable values are C(version), C(databases), C(settings), C(global_status), - C(users), C(engines), C(master_status), C(slave_status), C(slave_hosts). + C(users), C(users_privs), C(engines), C(master_status), C(slave_status), C(slave_hosts). - By default, collects all subsets. - You can use '!' before value (for example, C(!settings)) to exclude it from the information. - If you pass including and excluding values to the filter, for example, I(filter=!settings,version), @@ -74,6 +74,9 @@ EXAMPLES = r''' # Display only databases and users info: # ansible mysql-hosts -m mysql_info -a 'filter=databases,users' +# Display all users privileges: +# ansible mysql-hosts -m mysql_info -a 'filter=users_privs' + # Display only slave status: # ansible standby -m mysql_info -a 'filter=slave_status' @@ -186,6 +189,12 @@ users: type: dict sample: - { "localhost": { "root": { "Alter_priv": "Y", "Alter_routine_priv": "Y" } } } +users_privs: + description: Users privileges. + returned: if not excluded by filter + type: dict + sample: + - { "name": "user1", "host": "host.com", "priv": 'db1.*':'ALL' 'db2.tb1':'SELECT', pass_hash: '*1234567', resource_limits: { MAX_USER_CONNECTIONS: 100 } } engines: description: Information about the server's storage engines. returned: if not excluded by filter @@ -237,6 +246,10 @@ from ansible_collections.community.mysql.plugins.module_utils.mysql import ( mysql_driver_fail_msg, get_connector_name, get_connector_version, + get_server_version, +) +from ansible_collections.community.mysql.plugins.module_utils.user import ( + get_grants, ) from ansible.module_utils.six import iteritems from ansible.module_utils._text import to_native @@ -274,6 +287,7 @@ class MySQL_Info(object): 'global_status': {}, 'engines': {}, 'users': {}, + 'users_privs': {}, 'master_status': {}, 'slave_hosts': {}, 'slave_status': {}, @@ -342,6 +356,9 @@ class MySQL_Info(object): if 'users' in wanted: self.__get_users() + if 'users_privs' in wanted: + self.__get_users_privs() + if 'master_status' in wanted: self.__get_master_status() @@ -480,6 +497,29 @@ class MySQL_Info(object): if vname not in ('Host', 'User'): self.info['users'][host][user][vname] = self.__convert(val) + def __get_users_privs(self): + """Get user privileges.""" + try: + user = self.__exec_sql('SELECT * FROM mysql.user') + except Exception as e: + self.fail_json( + msg="mysql_info failed to retrieve the users: %s" % e) + + for line in user: + u = line['User'] + h = line['Host'] + key = u + '_' + h + + privs = get_grants(self.module, self.cursor, u, h) + + if not privs: + self.module.warn( + 'Fail to get privileges for user %s on host %s.' % (u, h)) + privs = {} + + self.info['users_privs'][key] = { + 'user': u, 'host': h, 'privs': privs} + def __get_databases(self, exclude_fields, return_empty_dbs): """Get info about databases.""" if not exclude_fields: diff --git a/plugins/modules/mysql_user.py b/plugins/modules/mysql_user.py index 3e914e6..93d659b 100644 --- a/plugins/modules/mysql_user.py +++ b/plugins/modules/mysql_user.py @@ -525,7 +525,7 @@ def main(): if subtract_privs: priv = None # avoid granting unwanted privileges reuse_existing_password = update_password == 'on_new_username' - result = user_add(cursor, user, host, host_all, password, encrypted, + result = user_add(module, 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'] diff --git a/tests/integration/targets/test_mysql_info/tasks/filter_users_privs.yml b/tests/integration/targets/test_mysql_info/tasks/filter_users_privs.yml new file mode 100644 index 0000000..068b860 --- /dev/null +++ b/tests/integration/targets/test_mysql_info/tasks/filter_users_privs.yml @@ -0,0 +1,13 @@ +--- + +- name: Collect info about users_privs + community.mysql.mysql_info: + login_user: '{{ mysql_user }}' + login_password: '{{ mysql_password }}' + login_host: '{{ mysql_host }}' + login_port: '{{ mysql_primary_port }}' + filter: + - users_privs + register: result + +- debug: var=result diff --git a/tests/integration/targets/test_mysql_info/tasks/main.yml b/tests/integration/targets/test_mysql_info/tasks/main.yml index be367f0..cf2a834 100644 --- a/tests/integration/targets/test_mysql_info/tasks/main.yml +++ b/tests/integration/targets/test_mysql_info/tasks/main.yml @@ -21,201 +21,205 @@ block: - # Create default MySQL config file with credentials - - name: mysql_info - create default config file - template: - src: my.cnf.j2 - dest: "{{ playbook_dir }}/root/.my.cnf" - mode: '0400' + # # Create default MySQL config file with credentials + # - name: mysql_info - create default config file + # template: + # src: my.cnf.j2 + # dest: "{{ playbook_dir }}/root/.my.cnf" + # mode: '0400' - # Create non-default MySQL config file with credentials - - name: mysql_info - create non-default config file - template: - src: my.cnf.j2 - dest: "{{ playbook_dir }}/root/non-default_my.cnf" - mode: '0400' + # # Create non-default MySQL config file with credentials + # - name: mysql_info - create non-default config file + # template: + # src: my.cnf.j2 + # dest: "{{ playbook_dir }}/root/non-default_my.cnf" + # mode: '0400' - ############### - # Do tests + # ############### + # # Do tests - # Access by default cred file - - name: mysql_info - collect default cred file - mysql_info: - login_user: '{{ mysql_user }}' - login_host: '{{ mysql_host }}' - login_port: '{{ mysql_primary_port }}' - config_file: "{{ playbook_dir }}/root/.my.cnf" - register: result + # # Access by default cred file + # - name: mysql_info - collect default cred file + # mysql_info: + # login_user: '{{ mysql_user }}' + # login_host: '{{ mysql_host }}' + # login_port: '{{ mysql_primary_port }}' + # config_file: "{{ playbook_dir }}/root/.my.cnf" + # register: result - - assert: - that: - - result is not changed - - db_version in result.version.full - - result.settings != {} - - result.global_status != {} - - result.databases != {} - - result.engines != {} - - result.users != {} + # - assert: + # that: + # - result is not changed + # - db_version in result.version.full + # - result.settings != {} + # - result.global_status != {} + # - result.databases != {} + # - result.engines != {} + # - result.users != {} - - name: mysql_info - Test connector informations display + # - name: mysql_info - Test connector informations display + # ansible.builtin.import_tasks: + # file: connector_info.yml + + # # Access by non-default cred file + # - name: mysql_info - check non-default cred file + # mysql_info: + # login_user: '{{ mysql_user }}' + # login_host: '{{ mysql_host }}' + # login_port: '{{ mysql_primary_port }}' + # config_file: "{{ playbook_dir }}/root/non-default_my.cnf" + # register: result + + # - assert: + # that: + # - result is not changed + # - result.version != {} + + # # Remove cred files + # - name: mysql_info - remove cred files + # file: + # path: '{{ item }}' + # state: absent + # loop: + # - "{{ playbook_dir }}/.my.cnf" + # - "{{ playbook_dir }}/non-default_my.cnf" + + # # Access with password + # - name: mysql_info - check access with password + # mysql_info: + # <<: *mysql_params + # register: result + + # - assert: + # that: + # - result is not changed + # - result.version != {} + + # # Test excluding + # - name: Collect all info except settings and users + # mysql_info: + # <<: *mysql_params + # filter: '!settings,!users' + # register: result + + # - assert: + # that: + # - result is not changed + # - result.version != {} + # - result.global_status != {} + # - result.databases != {} + # - result.engines != {} + # - result.settings is not defined + # - result.users is not defined + + # # Test including + # - name: Collect info only about version and databases + # mysql_info: + # <<: *mysql_params + # filter: + # - version + # - databases + # register: result + + # - assert: + # that: + # - result is not changed + # - result.version != {} + # - result.databases != {} + # - result.engines is not defined + # - result.settings is not defined + # - result.global_status is not defined + # - result.users is not defined + + # # Test exclude_fields: db_size + # # 'unsupported' element is passed to check that an unsupported value + # # won't break anything (will be ignored regarding to the module's documentation). + # - name: Collect info about databases excluding their sizes + # mysql_info: + # <<: *mysql_params + # filter: + # - databases + # exclude_fields: + # - db_size + # - unsupported + # register: result + + # - assert: + # that: + # - result is not changed + # - result.databases != {} + # - result.databases.mysql == {} + + # ######################################################## + # # Issue #65727, empty databases must be in returned dict + # # + # - name: Create empty database acme + # mysql_db: + # <<: *mysql_params + # name: acme + + # - name: Collect info about databases + # mysql_info: + # <<: *mysql_params + # filter: + # - databases + # return_empty_dbs: true + # register: result + + # # Check acme is in returned dict + # - assert: + # that: + # - result is not changed + # - result.databases.acme.size == 0 + # - result.databases.mysql != {} + + # - name: Collect info about databases excluding their sizes + # mysql_info: + # <<: *mysql_params + # filter: + # - databases + # exclude_fields: + # - db_size + # return_empty_dbs: true + # register: result + + # # Check acme is in returned dict + # - assert: + # that: + # - result is not changed + # - result.databases.acme == {} + # - result.databases.mysql == {} + + # - name: Remove acme database + # mysql_db: + # <<: *mysql_params + # name: acme + # state: absent + + # - include_tasks: issue-28.yml + + # # https://github.com/ansible-collections/community.mysql/issues/204 + # - name: Create database containing only views + # mysql_db: + # <<: *mysql_params + # name: allviews + + # - name: Create view + # mysql_query: + # <<: *mysql_params + # login_db: allviews + # query: 'CREATE VIEW v_today (today) AS SELECT CURRENT_DATE' + + # - name: Fetch info + # mysql_info: + # <<: *mysql_params + # register: result + + # - name: Check + # assert: + # that: + # - result.databases.allviews.size == 0 + + - name: Inport tasks file to tests users_privs filter ansible.builtin.import_tasks: - file: connector_info.yml - - # Access by non-default cred file - - name: mysql_info - check non-default cred file - mysql_info: - login_user: '{{ mysql_user }}' - login_host: '{{ mysql_host }}' - login_port: '{{ mysql_primary_port }}' - config_file: "{{ playbook_dir }}/root/non-default_my.cnf" - register: result - - - assert: - that: - - result is not changed - - result.version != {} - - # Remove cred files - - name: mysql_info - remove cred files - file: - path: '{{ item }}' - state: absent - loop: - - "{{ playbook_dir }}/.my.cnf" - - "{{ playbook_dir }}/non-default_my.cnf" - - # Access with password - - name: mysql_info - check access with password - mysql_info: - <<: *mysql_params - register: result - - - assert: - that: - - result is not changed - - result.version != {} - - # Test excluding - - name: Collect all info except settings and users - mysql_info: - <<: *mysql_params - filter: '!settings,!users' - register: result - - - assert: - that: - - result is not changed - - result.version != {} - - result.global_status != {} - - result.databases != {} - - result.engines != {} - - result.settings is not defined - - result.users is not defined - - # Test including - - name: Collect info only about version and databases - mysql_info: - <<: *mysql_params - filter: - - version - - databases - register: result - - - assert: - that: - - result is not changed - - result.version != {} - - result.databases != {} - - result.engines is not defined - - result.settings is not defined - - result.global_status is not defined - - result.users is not defined - - # Test exclude_fields: db_size - # 'unsupported' element is passed to check that an unsupported value - # won't break anything (will be ignored regarding to the module's documentation). - - name: Collect info about databases excluding their sizes - mysql_info: - <<: *mysql_params - filter: - - databases - exclude_fields: - - db_size - - unsupported - register: result - - - assert: - that: - - result is not changed - - result.databases != {} - - result.databases.mysql == {} - - ######################################################## - # Issue #65727, empty databases must be in returned dict - # - - name: Create empty database acme - mysql_db: - <<: *mysql_params - name: acme - - - name: Collect info about databases - mysql_info: - <<: *mysql_params - filter: - - databases - return_empty_dbs: true - register: result - - # Check acme is in returned dict - - assert: - that: - - result is not changed - - result.databases.acme.size == 0 - - result.databases.mysql != {} - - - name: Collect info about databases excluding their sizes - mysql_info: - <<: *mysql_params - filter: - - databases - exclude_fields: - - db_size - return_empty_dbs: true - register: result - - # Check acme is in returned dict - - assert: - that: - - result is not changed - - result.databases.acme == {} - - result.databases.mysql == {} - - - name: Remove acme database - mysql_db: - <<: *mysql_params - name: acme - state: absent - - - include_tasks: issue-28.yml - - # https://github.com/ansible-collections/community.mysql/issues/204 - - name: Create database containing only views - mysql_db: - <<: *mysql_params - name: allviews - - - name: Create view - mysql_query: - <<: *mysql_params - login_db: allviews - query: 'CREATE VIEW v_today (today) AS SELECT CURRENT_DATE' - - - name: Fetch info - mysql_info: - <<: *mysql_params - register: result - - - name: Check - assert: - that: - - result.databases.allviews.size == 0 + file: filter_users_privs.yml