diff --git a/lib/ansible/modules/database/postgresql/postgresql_db.py b/lib/ansible/modules/database/postgresql/postgresql_db.py index e29e5d65bc..796fdfade5 100644 --- a/lib/ansible/modules/database/postgresql/postgresql_db.py +++ b/lib/ansible/modules/database/postgresql/postgresql_db.py @@ -41,6 +41,11 @@ options: description: - Character classification (LC_CTYPE) to use in the database (e.g. lower, upper, ...) Must match LC_CTYPE of template database unless C(template0) is used as template. + session_role: + version_added: "2.8" + description: | + Switch to session_role after connecting. The specified session_role must be a role that the current login_user is a member of. + Permissions checking for SQL commands is carried out as though the session_role were the one that had logged in originally. state: description: | The database state. present implies that the database should be created if necessary. @@ -370,6 +375,7 @@ def main(): target=dict(default="", type="path"), target_opts=dict(default=""), maintenance_db=dict(default="postgres"), + session_role=dict(), )) module = AnsibleModule( @@ -391,6 +397,7 @@ def main(): state = module.params["state"] changed = False maintenance_db = module.params['maintenance_db'] + session_role = module.params["session_role"] # To use defaults values, keyword arguments must be absent, so # check which values are empty and don't include in the **kw @@ -439,6 +446,12 @@ def main(): except Exception as e: module.fail_json(msg="unable to connect to database: %s" % to_native(e), exception=traceback.format_exc()) + if session_role: + try: + cursor.execute('SET ROLE %s' % pg_quote_identifier(session_role, 'role')) + except Exception as e: + module.fail_json(msg="Could not switch role: %s" % to_native(e), exception=traceback.format_exc()) + try: if module.check_mode: if state == "absent": diff --git a/lib/ansible/modules/database/postgresql/postgresql_ext.py b/lib/ansible/modules/database/postgresql/postgresql_ext.py index 58f27b93bd..c1cd258631 100644 --- a/lib/ansible/modules/database/postgresql/postgresql_ext.py +++ b/lib/ansible/modules/database/postgresql/postgresql_ext.py @@ -66,6 +66,11 @@ options: description: - Database port to connect to. default: 5432 + session_role: + version_added: "2.8" + description: | + Switch to session_role after connecting. The specified session_role must be a role that the current login_user is a member of. + Permissions checking for SQL commands is carried out as though the session_role were the one that had logged in originally. state: description: - The database extension state @@ -116,6 +121,7 @@ else: from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.six import iteritems from ansible.module_utils._text import to_native +from ansible.module_utils.database import pg_quote_identifier class NotSupportedError(Exception): @@ -176,6 +182,7 @@ def main(): ssl_mode=dict(default='prefer', choices=[ 'disable', 'allow', 'prefer', 'require', 'verify-ca', 'verify-full']), ssl_rootcert=dict(default=None), + session_role=dict(), ), supports_check_mode=True ) @@ -189,6 +196,7 @@ def main(): state = module.params["state"] cascade = module.params["cascade"] sslrootcert = module.params["ssl_rootcert"] + session_role = module.params["session_role"] changed = False # To use defaults values, keyword arguments must be absent, so @@ -235,6 +243,12 @@ def main(): except Exception as e: module.fail_json(msg="unable to connect to database: %s" % to_native(e), exception=traceback.format_exc()) + if session_role: + try: + cursor.execute('SET ROLE %s' % pg_quote_identifier(session_role, 'role')) + except Exception as e: + module.fail_json(msg="Could not switch role: %s" % to_native(e), exception=traceback.format_exc()) + try: if module.check_mode: if state == "present": diff --git a/lib/ansible/modules/database/postgresql/postgresql_lang.py b/lib/ansible/modules/database/postgresql/postgresql_lang.py index a92b3bc9c9..1dcf861943 100644 --- a/lib/ansible/modules/database/postgresql/postgresql_lang.py +++ b/lib/ansible/modules/database/postgresql/postgresql_lang.py @@ -77,6 +77,11 @@ options: description: - Host running PostgreSQL where you want to execute the actions. default: localhost + session_role: + version_added: "2.8" + description: | + Switch to session_role after connecting. The specified session_role must be a role that the current login_user is a member of. + Permissions checking for SQL commands is carried out as though the session_role were the one that had logged in originally. state: description: - The state of the language for the selected database @@ -163,6 +168,7 @@ else: from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.six import iteritems from ansible.module_utils._text import to_native +from ansible.module_utils.database import pg_quote_identifier def lang_exists(cursor, lang): @@ -230,6 +236,7 @@ def main(): ssl_mode=dict(default='prefer', choices=[ 'disable', 'allow', 'prefer', 'require', 'verify-ca', 'verify-full']), ssl_rootcert=dict(default=None), + session_role=dict(), ), supports_check_mode=True ) @@ -242,6 +249,7 @@ def main(): cascade = module.params["cascade"] fail_on_drop = module.params["fail_on_drop"] sslrootcert = module.params["ssl_rootcert"] + session_role = module.params["session_role"] if not postgresqldb_found: module.fail_json(msg="the python psycopg2 module is required") @@ -281,6 +289,12 @@ def main(): except Exception as e: module.fail_json(msg="unable to connect to database: %s" % to_native(e), exception=traceback.format_exc()) + if session_role: + try: + cursor.execute('SET ROLE %s' % pg_quote_identifier(session_role, 'role')) + except Exception as e: + module.fail_json(msg="Could not switch role: %s" % to_native(e), exception=traceback.format_exc()) + changed = False kw = {'db': db, 'lang': lang, 'trust': trust} diff --git a/lib/ansible/modules/database/postgresql/postgresql_privs.py b/lib/ansible/modules/database/postgresql/postgresql_privs.py index 59cc98f384..5dac25a35f 100644 --- a/lib/ansible/modules/database/postgresql/postgresql_privs.py +++ b/lib/ansible/modules/database/postgresql/postgresql_privs.py @@ -70,6 +70,11 @@ options: for the implicitly defined PUBLIC group. - 'Alias: I(role)' required: yes + session_role: + version_added: "2.8" + description: | + Switch to session_role after connecting. The specified session_role must be a role that the current login_user is a member of. + Permissions checking for SQL commands is carried out as though the session_role were the one that had logged in originally. grant_option: description: - Whether C(role) may grant/revoke the specified privileges/group @@ -668,6 +673,7 @@ def main(): objs=dict(required=False, aliases=['obj']), schema=dict(required=False), roles=dict(required=True, aliases=['role']), + session_role=dict(required=False), grant_option=dict(required=False, type='bool', aliases=['admin_option']), host=dict(default='', aliases=['login_host']), @@ -722,6 +728,12 @@ def main(): # We raise this when the psycopg library is too old module.fail_json(msg=to_native(e)) + if p.session_role: + try: + conn.cursor.execute('SET ROLE %s' % pg_quote_identifier(p.session_role, 'role')) + except Exception as e: + module.fail_json(msg="Could not switch to role %s: %s" % (p.session_role, to_native(e)), exception=traceback.format_exc()) + try: # privs if p.privs: diff --git a/lib/ansible/modules/database/postgresql/postgresql_schema.py b/lib/ansible/modules/database/postgresql/postgresql_schema.py index 914155ed8f..0cb6f7c4c9 100644 --- a/lib/ansible/modules/database/postgresql/postgresql_schema.py +++ b/lib/ansible/modules/database/postgresql/postgresql_schema.py @@ -49,6 +49,11 @@ options: description: - Database port to connect to. default: 5432 + session_role: + version_added: "2.8" + description: | + Switch to session_role after connecting. The specified session_role must be a role that the current login_user is a member of. + Permissions checking for SQL commands is carried out as though the session_role were the one that had logged in originally. state: description: - The schema state. @@ -218,6 +223,7 @@ def main(): ssl_mode=dict(default='prefer', choices=[ 'disable', 'allow', 'prefer', 'require', 'verify-ca', 'verify-full']), ssl_rootcert=dict(default=None), + session_role=dict(), ), supports_check_mode=True ) @@ -230,6 +236,7 @@ def main(): state = module.params["state"] sslrootcert = module.params["ssl_rootcert"] cascade_drop = module.params["cascade_drop"] + session_role = module.params["session_role"] changed = False # To use defaults values, keyword arguments must be absent, so @@ -277,6 +284,12 @@ def main(): except Exception as e: module.fail_json(msg="unable to connect to database: %s" % to_native(e), exception=traceback.format_exc()) + if session_role: + try: + cursor.execute('SET ROLE %s' % pg_quote_identifier(session_role, 'role')) + except Exception as e: + module.fail_json(msg="Could not switch role: %s" % to_native(e), exception=traceback.format_exc()) + try: if module.check_mode: if state == "absent": diff --git a/lib/ansible/modules/database/postgresql/postgresql_user.py b/lib/ansible/modules/database/postgresql/postgresql_user.py index f86d8afb69..08c79a65fa 100644 --- a/lib/ansible/modules/database/postgresql/postgresql_user.py +++ b/lib/ansible/modules/database/postgresql/postgresql_user.py @@ -86,6 +86,11 @@ options: - "PostgreSQL role attributes string in the format: CREATEDB,CREATEROLE,SUPERUSER." - Note that '[NO]CREATEUSER' is deprecated. choices: ["[NO]SUPERUSER", "[NO]CREATEROLE", "[NO]CREATEDB", "[NO]INHERIT", "[NO]LOGIN", "[NO]REPLICATION", "[NO]BYPASSRLS"] + session_role: + version_added: "2.8" + description: | + Switch to session_role after connecting. The specified session_role must be a role that the current login_user is a member of. + Permissions checking for SQL commands is carried out as though the session_role were the one that had logged in originally. state: description: - The user (role) state. @@ -743,7 +748,8 @@ def main(): ssl_mode=dict(default='prefer', choices=[ 'disable', 'allow', 'prefer', 'require', 'verify-ca', 'verify-full']), ssl_rootcert=dict(default=None), - conn_limit=dict(type='int', default=None) + conn_limit=dict(type='int', default=None), + session_role=dict(), )) module = AnsibleModule( argument_spec=argument_spec, @@ -755,6 +761,7 @@ def main(): state = module.params["state"] fail_on_user = module.params["fail_on_user"] db = module.params["db"] + session_role = module.params["session_role"] if db == '' and module.params["priv"] is not None: module.fail_json(msg="privileges require a database to be specified") privs = parse_privs(module.params["priv"], db) @@ -808,6 +815,12 @@ def main(): except Exception as e: module.fail_json(msg="unable to connect to database: %s" % to_native(e), exception=traceback.format_exc()) + if session_role: + try: + cursor.execute('SET ROLE %s' % pg_quote_identifier(session_role, 'role')) + except Exception as e: + module.fail_json(msg="Could not switch role: %s" % to_native(e), exception=traceback.format_exc()) + try: role_attr_flags = parse_role_attrs(cursor, module.params["role_attr_flags"]) except InvalidFlagsError as e: diff --git a/test/integration/targets/postgresql/defaults/main.yml b/test/integration/targets/postgresql/defaults/main.yml index 0dee1f0fc3..af5a5fbe4d 100644 --- a/test/integration/targets/postgresql/defaults/main.yml +++ b/test/integration/targets/postgresql/defaults/main.yml @@ -6,3 +6,5 @@ db_user2: 'ansible_db_user2' db_user3: 'ansible_db_user3' tmp_dir: '/tmp' +db_session_role1: 'session_role1' +db_session_role2: 'session_role2' \ No newline at end of file diff --git a/test/integration/targets/postgresql/tasks/main.yml b/test/integration/targets/postgresql/tasks/main.yml index 48992e04b4..9a71b283d9 100644 --- a/test/integration/targets/postgresql/tasks/main.yml +++ b/test/integration/targets/postgresql/tasks/main.yml @@ -762,6 +762,9 @@ that: - "result.stdout_lines[-1] == '(0 rows)'" +# Verify different session_role scenarios +- include: session_role.yml + # dump/restore tests per format # ============================================================ - include: state_dump_restore.yml test_fixture=user file=dbdata.sql diff --git a/test/integration/targets/postgresql/tasks/session_role.yml b/test/integration/targets/postgresql/tasks/session_role.yml new file mode 100644 index 0000000000..d75cfd801b --- /dev/null +++ b/test/integration/targets/postgresql/tasks/session_role.yml @@ -0,0 +1,254 @@ +- name: Check that becoming an non-existing user throws an error + become_user: "{{ pg_user }}" + become: True + postgresql_db: + state: present + name: "{{ db_name }}" + login_user: "{{ pg_user }}" + session_role: "{{ db_session_role1 }}" + register: result + ignore_errors: True + +- assert: + that: + - 'result.failed == True' + +- name: Create a high privileged user + become: True + become_user: "{{ pg_user }}" + postgresql_user: + name: "{{ db_session_role1 }}" + state: "present" + password: "password" + role_attr_flags: "CREATEDB,LOGIN,CREATEROLE" + login_user: "{{ pg_user }}" + db: postgres + +- name: Create a low privileged user using the newly created user + become: True + become_user: "{{ pg_user }}" + postgresql_user: + name: "{{ db_session_role2 }}" + state: "present" + password: "password" + role_attr_flags: "LOGIN" + login_user: "{{ pg_user }}" + session_role: "{{ db_session_role1 }}" + db: postgres + +- name: Create DB as session_role + become_user: "{{ pg_user }}" + become: True + postgresql_db: + state: present + name: "{{ db_session_role1 }}" + login_user: "{{ pg_user }}" + session_role: "{{ db_session_role1 }}" + register: result + +- name: Check that database created and is owned by correct user + become_user: "{{ pg_user }}" + become: True + shell: echo "select rolname from pg_database join pg_roles on datdba = pg_roles.oid where datname = '{{ db_session_role1 }}';" | psql -AtXq postgres + register: result + +- assert: + that: + - "result.stdout_lines[-1] == '{{ db_session_role1 }}'" + +- name: Fail when creating database as low privileged user + become_user: "{{ pg_user }}" + become: True + postgresql_db: + state: present + name: "{{ db_session_role2 }}" + login_user: "{{ pg_user }}" + session_role: "{{ db_session_role2 }}" + register: result + ignore_errors: True + +- assert: + that: + - 'result.failed == True' + +- name: Create schema in own database + become_user: "{{ pg_user }}" + become: True + postgresql_schema: + database: "{{ db_session_role1 }}" + login_user: "{{ pg_user }}" + name: "{{ db_session_role1 }}" + session_role: "{{ db_session_role1 }}" + +- name: Create schema in own database, should be owned by session_role + become_user: "{{ pg_user }}" + become: True + postgresql_schema: + database: "{{ db_session_role1 }}" + login_user: "{{ pg_user }}" + name: "{{ db_session_role1 }}" + owner: "{{ db_session_role1 }}" + register: result + +- assert: + that: + - result.changed == False + +- name: Fail when creating schema in postgres database as a regular user + become_user: "{{ pg_user }}" + become: True + postgresql_schema: + database: postgres + login_user: "{{ pg_user }}" + name: "{{ db_session_role1 }}" + session_role: "{{ db_session_role1 }}" + ignore_errors: True + register: result + +- assert: + that: + - 'result.failed == True' + +# PostgreSQL introduced extensions in 9.1, some checks are still run against older versions, therefore we need to ensure +# we only run these tests against supported PostgreSQL databases + +- name: Check that pg_extension exists (postgresql >= 9.1) + become_user: "{{ pg_user }}" + become: True + shell: echo "select count(*) from pg_class where relname='pg_extension' and relkind='r'" | psql -AtXq postgres + register: pg_extension + +- name: Remove plpgsql from testdb using postgresql_ext + become_user: "{{ pg_user }}" + become: True + postgresql_ext: + name: plpgsql + db: "{{ db_session_role1 }}" + login_user: "{{ pg_user }}" + state: absent + when: + "pg_extension.stdout_lines[-1] == '1'" + +- name: Fail when trying to create an extension as a mere mortal user + become_user: "{{ pg_user }}" + become: True + postgresql_ext: + name: plpgsql + db: "{{ db_session_role1 }}" + login_user: "{{ pg_user }}" + session_role: "{{ db_session_role2 }}" + ignore_errors: True + register: result + when: + "pg_extension.stdout_lines[-1] == '1'" + +- assert: + that: + - 'result.failed == True' + when: + "pg_extension.stdout_lines[-1] == '1'" + +- name: Install extension as session_role + become_user: "{{ pg_user }}" + become: True + postgresql_ext: + name: plpgsql + db: "{{ db_session_role1 }}" + login_user: "{{ pg_user }}" + session_role: "{{ db_session_role1 }}" + when: + "pg_extension.stdout_lines[-1] == '1'" + +- name: Check that extension is created and is owned by session_role + become_user: "{{ pg_user }}" + become: True + shell: echo "select rolname from pg_extension join pg_roles on extowner=pg_roles.oid where extname='plpgsql';" | psql -AtXq "{{ db_session_role1 }}" + register: result + when: + "pg_extension.stdout_lines[-1] == '1'" + +- assert: + that: + - "result.stdout_lines[-1] == '{{ db_session_role1 }}'" + when: + "pg_extension.stdout_lines[-1] == '1'" + +- name: Remove plpgsql from testdb using postgresql_ext + become_user: "{{ pg_user }}" + become: True + postgresql_ext: + name: plpgsql + db: "{{ db_session_role1 }}" + login_user: "{{ pg_user }}" + state: absent + when: + "pg_extension.stdout_lines[-1] == '1'" + +# End of postgresql_ext conditional tests against PostgreSQL 9.1+ + +- name: Create table to be able to grant privileges + become_user: "{{ pg_user }}" + become: True + shell: echo "CREATE TABLE test(i int); CREATE TABLE test2(i int);" | psql -AtXq "{{ db_session_role1 }}" + +- name: Grant all privileges on test1 table to low privileged user + become_user: "{{ pg_user }}" + become: True + postgresql_privs: + db: "{{ db_session_role1 }}" + type: table + objs: test + roles: "{{ db_session_role2 }}" + login_user: "{{ pg_user }}" + privs: select + admin_option: yes + +- name: Verify admin option was successful for grants + become_user: "{{ pg_user }}" + become: True + postgresql_privs: + db: "{{ db_session_role1 }}" + type: table + objs: test + roles: "{{ db_session_role1 }}" + login_user: "{{ pg_user }}" + privs: select + session_role: "{{ db_session_role2 }}" + +- name: Verify no grants can be granted for test2 table + become_user: "{{ pg_user }}" + become: True + postgresql_privs: + db: "{{ db_session_role1 }}" + type: table + objs: test2 + roles: "{{ db_session_role1 }}" + login_user: "{{ pg_user }}" + privs: update + session_role: "{{ db_session_role2 }}" + ignore_errors: True + register: result + +- assert: + that: + - 'result.failed == True' + +- name: Drop test db + become_user: "{{ pg_user }}" + become: True + postgresql_db: + state: absent + name: "{{ db_session_role1 }}" + login_user: "{{ pg_user }}" + +- name: Drop test users + become: True + become_user: "{{ pg_user }}" + postgresql_user: + name: "{{ item }}" + state: absent + login_user: "{{ pg_user }}" + db: postgres + with_items: + - "{{ db_session_role1 }}" + - "{{ db_session_role2 }}"