From 08c96d94e675762acf44fa9632e6ac2fe4249ddf Mon Sep 17 00:00:00 2001
From: Andrew Klychkov <aaklychkov@mail.ru>
Date: Thu, 8 Oct 2020 08:21:39 +0300
Subject: [PATCH] postgresql_privs: add procedure type support (#1048)

* postgresql_privs: add procedure type support

* add CI tests

* add changelog fragment

* change

* improve doc formatting
---
 ...48-postgresql_privs_add_procedure_type.yml |   2 +
 .../database/postgresql/postgresql_privs.py   |  60 ++++++--
 .../tasks/postgresql_privs_general.yml        | 144 ++++++++++++++++++
 3 files changed, 192 insertions(+), 14 deletions(-)
 create mode 100644 changelogs/fragments/1048-postgresql_privs_add_procedure_type.yml

diff --git a/changelogs/fragments/1048-postgresql_privs_add_procedure_type.yml b/changelogs/fragments/1048-postgresql_privs_add_procedure_type.yml
new file mode 100644
index 0000000000..f5702cc24e
--- /dev/null
+++ b/changelogs/fragments/1048-postgresql_privs_add_procedure_type.yml
@@ -0,0 +1,2 @@
+minor_changes:
+- postgresql_privs - add ``procedure`` type support (https://github.com/ansible-collections/community.general/issues/1002).
diff --git a/plugins/modules/database/postgresql/postgresql_privs.py b/plugins/modules/database/postgresql/postgresql_privs.py
index 1bdccf1ed3..695c7bd46b 100644
--- a/plugins/modules/database/postgresql/postgresql_privs.py
+++ b/plugins/modules/database/postgresql/postgresql_privs.py
@@ -42,31 +42,33 @@ options:
     description:
     - Type of database object to set privileges on.
     - The C(default_privs) choice is available starting at version 2.7.
-    - The C(foreign_data_wrapper) and C(foreign_server) object types are available since Ansible version '2.8'.
-    - The C(type) choice is available since Ansible version '2.10'.
+    - The C(foreign_data_wrapper) and C(foreign_server) object types are available since Ansible version 2.8.
+    - The C(type) choice is available since Ansible version 2.10.
+    - The C(procedure) is supported since collection version 1.3.0 and PostgreSQL 11.
     type: str
     default: table
     choices: [ database, default_privs, foreign_data_wrapper, foreign_server, function,
-               group, language, table, tablespace, schema, sequence, type ]
+               group, language, table, tablespace, schema, sequence, type , procedure]
   objs:
     description:
     - Comma separated list of database objects to set privileges on.
-    - If I(type) is C(table), C(partition table), C(sequence) or C(function),
+    - If I(type) is C(table), C(partition table), C(sequence), C(function) or C(procedure),
       the special valueC(ALL_IN_SCHEMA) can be provided instead to specify all
       database objects of type I(type) in the schema specified via I(schema).
       (This also works with PostgreSQL < 9.0.) (C(ALL_IN_SCHEMA) is available
        for C(function) and C(partition table) since Ansible 2.8)
+    - C(procedure) is supported since PostgreSQL 11 and M(community.general) collection 1.3.0.
     - If I(type) is C(database), this parameter can be omitted, in which case
       privileges are set for the database specified via I(database).
-    - 'If I(type) is I(function), colons (":") in object names will be
-      replaced with commas (needed to specify function signatures, see examples)'
+    - If I(type) is I(function) or I(procedure), colons (":") in object names will be
+      replaced with commas (needed to specify signatures, see examples).
     type: str
     aliases:
     - obj
   schema:
     description:
     - Schema that contains the database objects specified via I(objs).
-    - May only be provided if I(type) is C(table), C(sequence), C(function), C(type),
+    - May only be provided if I(type) is C(table), C(sequence), C(function), C(procedure), C(type),
       or C(default_privs). Defaults to C(public) in these cases.
     - Pay attention, for embedded types when I(type=type)
       I(schema) can be C(pg_catalog) or C(information_schema) respectively.
@@ -369,7 +371,19 @@ EXAMPLES = r'''
     objs: ALL_IN_SCHEMA
     schema: common
 
-# Available since Ansible 2.8
+# Available since collection version 1.3.0
+# Grant 'execute' permissions on all procedures in schema 'common' to role 'caller'
+# Needs PostreSQL 11 or higher and community.general 1.3.0 or higher
+- name: GRANT EXECUTE ON ALL PROCEDURES IN SCHEMA common TO caller
+  community.general.postgresql_privs:
+    type: prucedure
+    state: present
+    privs: EXECUTE
+    roles: caller
+    objs: ALL_IN_SCHEMA
+    schema: common
+
+# Available since version 2.8
 # ALTER DEFAULT PRIVILEGES FOR ROLE librarian IN SCHEMA library GRANT SELECT ON TABLES TO reader
 # GRANT SELECT privileges for new TABLES objects created by librarian as
 # default to the role reader.
@@ -574,6 +588,21 @@ class Connection(object):
         self.cursor.execute(query, (schema,))
         return ["%s(%s)" % (t[0], t[1]) for t in self.cursor.fetchall()]
 
+    def get_all_procedures_in_schema(self, schema):
+        if self.pg_version < 110000:
+            raise Error("PostgreSQL verion must be >= 11 for type=procedure. Exit")
+
+        if not self.schema_exists(schema):
+            raise Error('Schema "%s" does not exist.' % schema)
+
+        query = ("SELECT p.proname, oidvectortypes(p.proargtypes) "
+                 "FROM pg_catalog.pg_proc p "
+                 "JOIN pg_namespace n ON n.oid = p.pronamespace "
+                 "WHERE nspname = %s and p.prokind = 'p'")
+
+        self.cursor.execute(query, (schema,))
+        return ["%s(%s)" % (t[0], t[1]) for t in self.cursor.fetchall()]
+
     # Methods for getting access control lists and group membership info
 
     # To determine whether anything has changed after granting/revoking
@@ -699,7 +728,7 @@ class Connection(object):
             get_status = partial(self.get_table_acls, schema_qualifier)
         elif obj_type == 'sequence':
             get_status = partial(self.get_sequence_acls, schema_qualifier)
-        elif obj_type == 'function':
+        elif obj_type in ('function', 'procedure'):
             get_status = partial(self.get_function_acls, schema_qualifier)
         elif obj_type == 'schema':
             get_status = self.get_schema_acls
@@ -727,13 +756,13 @@ class Connection(object):
             return False
 
         # obj_ids: quoted db object identifiers (sometimes schema-qualified)
-        if obj_type == 'function':
+        if obj_type in ('function', 'procedure'):
             obj_ids = []
             for obj in objs:
                 try:
                     f, args = obj.split('(', 1)
                 except Exception:
-                    raise Error('Illegal function signature: "%s".' % obj)
+                    raise Error('Illegal function / procedure signature: "%s".' % obj)
                 obj_ids.append('"%s"."%s"(%s' % (schema_qualifier, f, args))
         elif obj_type in ['table', 'sequence', 'type']:
             obj_ids = ['"%s"."%s"' % (schema_qualifier, o) for o in objs]
@@ -749,7 +778,7 @@ class Connection(object):
             set_what = ','.join(privs)
         else:
             # function types are already quoted above
-            if obj_type != 'function':
+            if obj_type not in ('function', 'procedure'):
                 obj_ids = [pg_quote_identifier(i, 'table') for i in obj_ids]
             # Note: obj_type has been checked against a set of string literals
             # and privs was escaped when it was parsed
@@ -961,6 +990,7 @@ def main():
                   choices=['table',
                            'sequence',
                            'function',
+                           'procedure',
                            'database',
                            'schema',
                            'language',
@@ -997,7 +1027,7 @@ def main():
     # Create type object as namespace for module params
     p = type('Params', (), module.params)
     # param "schema": default, allowed depends on param "type"
-    if p.type in ['table', 'sequence', 'function', 'type', 'default_privs']:
+    if p.type in ['table', 'sequence', 'function', 'procedure', 'type', 'default_privs']:
         p.schema = p.schema or 'public'
     elif p.schema:
         module.fail_json(msg='Argument "schema" is not allowed '
@@ -1060,6 +1090,8 @@ def main():
             objs = conn.get_all_sequences_in_schema(p.schema)
         elif p.type == 'function' and p.objs == 'ALL_IN_SCHEMA':
             objs = conn.get_all_functions_in_schema(p.schema)
+        elif p.type == 'procedure' and p.objs == 'ALL_IN_SCHEMA':
+            objs = conn.get_all_procedures_in_schema(p.schema)
         elif p.type == 'default_privs':
             if p.objs == 'ALL_DEFAULT':
                 objs = frozenset(VALID_DEFAULT_OBJS.keys())
@@ -1078,7 +1110,7 @@ def main():
             objs = p.objs.split(',')
 
             # function signatures are encoded using ':' to separate args
-            if p.type == 'function':
+            if p.type in ('function', 'procedure'):
                 objs = [obj.replace(':', ',') for obj in objs]
 
         # roles
diff --git a/tests/integration/targets/postgresql_privs/tasks/postgresql_privs_general.yml b/tests/integration/targets/postgresql_privs/tasks/postgresql_privs_general.yml
index 4b12bf2e83..54784fb5da 100644
--- a/tests/integration/targets/postgresql_privs/tasks/postgresql_privs_general.yml
+++ b/tests/integration/targets/postgresql_privs/tasks/postgresql_privs_general.yml
@@ -682,6 +682,150 @@
     - result is not changed
   when: postgres_version_resp.stdout is version('11', '>=')
 
+###########################
+# Test for procedure type #
+###########################
+- name: Create another procedure for tests
+  postgresql_query:
+    query: "CREATE PROCEDURE mock_procedure1(int, int) LANGUAGE SQL AS $$ SELECT 1; $$;"
+    db: "{{ db_name }}"
+    login_user: "{{ db_user3 }}"
+    login_password: password
+  when: postgres_version_resp.stdout is version('11', '>=')
+
+- name: Grant privs on procedure
+  postgresql_privs:
+    type: procedure
+    state: present
+    privs: EXECUTE
+    roles: "{{ db_user2 }}"
+    objs: 'mock_procedure1(int:int)'
+    schema: public
+    db: "{{ db_name }}"
+    login_user: "{{ db_user3 }}"
+    login_password: password
+  register: result
+  when: postgres_version_resp.stdout is version('11', '>=')
+
+- assert:
+    that:
+    - result is changed
+  when: postgres_version_resp.stdout is version('11', '>=')
+
+- name: Grant privs on procedure again
+  postgresql_privs:
+    type: procedure
+    state: present
+    privs: EXECUTE
+    roles: "{{ db_user2 }}"
+    objs: 'mock_procedure1(int:int)'
+    schema: public
+    db: "{{ db_name }}"
+    login_user: "{{ db_user3 }}"
+    login_password: password
+  register: result
+  when: postgres_version_resp.stdout is version('11', '>=')
+
+- assert:
+    that:
+    - result is not changed
+  when: postgres_version_resp.stdout is version('11', '>=')
+
+- name: Revoke procedure privs
+  postgresql_privs:
+    type: procedure
+    state: absent
+    privs: EXECUTE
+    roles: "{{ db_user2 }}"
+    objs: 'mock_procedure1(int:int)'
+    schema: public
+    db: "{{ db_name }}"
+    login_user: "{{ db_user3 }}"
+    login_password: password
+  register: result
+  when: postgres_version_resp.stdout is version('11', '>=')
+
+- assert:
+    that:
+    - result is changed
+  when: postgres_version_resp.stdout is version('11', '>=')
+
+- name: Revoke procedure privs again
+  postgresql_privs:
+    type: procedure
+    state: absent
+    privs: EXECUTE
+    roles: "{{ db_user2 }}"
+    objs: 'mock_procedure1(int:int)'
+    schema: public
+    db: "{{ db_name }}"
+    login_user: "{{ db_user3 }}"
+    login_password: password
+  register: result
+  when: postgres_version_resp.stdout is version('11', '>=')
+
+- assert:
+    that:
+    - result is not changed
+  when: postgres_version_resp.stdout is version('11', '>=')
+
+- name: Grant procedure privs for all object in schema
+  postgresql_privs:
+    type: procedure
+    state: present
+    privs: ALL
+    roles: "{{ db_user2 }}"
+    objs: ALL_IN_SCHEMA
+    schema: public
+    db: "{{ db_name }}"
+    login_user: "{{ db_user3 }}"
+    login_password: password
+  register: result
+  when: postgres_version_resp.stdout is version('11', '>=')
+
+- assert:
+    that:
+    - result is changed
+  when: postgres_version_resp.stdout is version('11', '>=')
+
+- name: Grant procedure privs for all object in schema again
+  postgresql_privs:
+    type: procedure
+    state: present
+    privs: ALL
+    roles: "{{ db_user2 }}"
+    objs: ALL_IN_SCHEMA
+    schema: public
+    db: "{{ db_name }}"
+    login_user: "{{ db_user3 }}"
+    login_password: password
+  register: result
+  when: postgres_version_resp.stdout is version('11', '>=')
+
+- assert:
+    that:
+    - result is not changed
+  when: postgres_version_resp.stdout is version('11', '>=')
+
+- name: Revoke procedure privs for all object in schema
+  postgresql_privs:
+    type: procedure
+    state: absent
+    privs: ALL
+    roles: "{{ db_user2 }}"
+    objs: ALL_IN_SCHEMA
+    schema: public
+    db: "{{ db_name }}"
+    login_user: "{{ db_user3 }}"
+    login_password: password
+  register: result
+  when: postgres_version_resp.stdout is version('11', '>=')
+
+- assert:
+    that:
+    - result is changed
+  when: postgres_version_resp.stdout is version('11', '>=')
+
 #################################################
 # Test ALL_IN_SCHEMA for 'partioned tables type #
 #################################################