diff --git a/library/postgresql_privs b/library/postgresql_privs
new file mode 100644
index 0000000000..5013f11776
--- /dev/null
+++ b/library/postgresql_privs
@@ -0,0 +1,600 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# This file is part of Ansible
+#
+# Ansible is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Ansible is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Ansible. If not, see .
+
+DOCUMENTATION = """
+---
+module: postgresql_privs
+short_description: Grant or revoke privileges on PostgreSQL database objects.
+description:
+ - Grant or revoke privileges on PostgreSQL database objects.
+ - This module is basically a wrapper around most of the functionality of
+ PostgreSQL's GRANT and REVOKE statements with detection of changes
+ (GRANT/REVOKE I(privs) ON I(type) I(objs) TO/FROM I(roles))
+options:
+ database:
+ description:
+ - Name of database to connect to.
+ - 'Alias: I(db)'
+ required: yes
+ state:
+ description:
+ - If C(present), the specified privileges are granted, if C(absent) they
+ are revoked.
+ required: no
+ default: present
+ choices: [present, absent]
+ privs:
+ description:
+ - Comma separated list of privileges to grant/revoke.
+ - 'Alias: I(priv)'
+ required: no
+ type:
+ description:
+ - Type of database object to set privileges on.
+ required: no
+ default: table
+ choices: [table, sequence, function, database,
+ schema, language, tablespace, group]
+ objs:
+ description:
+ - Comma separated list of database objects to set privileges on.
+ - If I(type) is C(table) or C(sequence), the special value
+ C(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.)
+ - 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)'
+ - 'Alias: I(obj)'
+ required: no
+ schema:
+ description:
+ - Schema that contains the database objects specified via I(objs).
+ - May only be provided if I(type) is C(table), C(sequence) or
+ C(function). Defaults to C(public) in these cases.
+ required: no
+ roles:
+ description:
+ - Comma separated list of role (user/group) names to set permissions for.
+ - The special value C(PUBLIC) can be provided instead to set permissions
+ for the implicitly defined PUBLIC group.
+ - 'Alias: I(role)'
+ required: yes
+ grant_option:
+ description:
+ - Whether C(role) may grant/revoke the specified privileges/group
+ memberships to others.
+ - Set to C(no) to revoke GRANT OPTION, leave unspecified to
+ make no changes.
+ - I(grant_option) only has an effect if I(state) is C(present).
+ - 'Alias: I(admin_option)'
+ required: no
+ choices: [yes, no]
+ host:
+ description:
+ - Database host address. If unspecified, connect via Unix socket.
+ - 'Alias: I(login_host)'
+ default: null
+ required: no
+ port:
+ description:
+ - Database port to connect to.
+ required: no
+ default: 5432
+ login:
+ description:
+ - The username to authenticate with.
+ - 'Alias: I(login_user)'
+ default: postgres
+ password:
+ description:
+ - The password to authenticate with.
+ - 'Alias: I(login_password))'
+ default: null
+ required: no
+notes:
+ - Default authentication assumes that postgresql_privs is run by the
+ C(postgres) user on the remote host. (Ansible's C(user) or C(sudo-user)).
+ - This module requires Python package I(psycopg2) to be installed on the
+ remote host. In the default case of the remote host also being the
+ PostgreSQL server, PostgreSQL has to be installed there as well, obviously.
+ For Debian/Ubuntu-based systems, install packages I(postgresql) and
+ I(python-psycopg2).
+ - Parameters that accept comma separated lists (I(privs), I(objs), I(roles))
+ have singular alias names (I(priv), I(obj), I(role)).
+ - To revoke only C(GRANT OPTION) for a specific object, set I(state) to
+ C(present) and I(grant_option) to C(no) (see examples).
+ - Note that when revoking privileges from a role R, this role may still have
+ access via privileges granted to any role R is a member of including
+ C(PUBLIC).
+ - Note that when revoking privileges from a role R, you do so as the user
+ specified via I(login). If R has been granted the same privileges by
+ another user also, R can still access database objects via these privileges.
+ - When revoking privileges, C(RESTRICT) is assumed (see PostgreSQL docs).
+requirements: [psycopg2]
+author: Bernhard Weitzhofer
+"""
+
+EXAMPLES = """
+# On database "library":
+# GRANT SELECT, INSERT, UPDATE ON TABLE public.books, public.authors
+# TO librarian, reader WITH GRANT OPTION
+postgresql_privs: >
+ database=library
+ state=present
+ privs=SELECT,INSERT,UPDATE
+ type=table
+ objs=books,authors
+ schema=public
+ roles=librarian,reader
+ grant_option=yes
+
+# Same as above leveraging default values:
+postgresql_privs: >
+ db=library
+ privs=SELECT,INSERT,UPDATE
+ objs=books,authors
+ roles=librarian,reader
+ grant_option=yes
+
+# REVOKE GRANT OPTION FOR INSERT ON TABLE books FROM reader
+# Note that role "reader" will be *granted* INSERT privilege itself if this
+# isn't already the case (since state=present).
+postgresql_privs: >
+ db=library
+ state=present
+ priv=INSERT
+ obj=books
+ role=reader
+ grant_option=no
+
+# REVOKE INSERT, UPDATE ON ALL TABLES IN SCHEMA public FROM reader
+# "public" is the default schema. This also works for PostgreSQL 8.x.
+postgresql_privs: >
+ db=library
+ state=absent
+ privs=INSERT,UPDATE
+ objs=ALL_IN_SCHEMA
+ role=reader
+
+# GRANT ALL PRIVILEGES ON SCHEMA public, math TO librarian
+postgresql_privs: >
+ db=library
+ privs=ALL
+ type=schema
+ objs=public,math
+ role=librarian
+
+# GRANT ALL PRIVILEGES ON FUNCTION math.add(int, int) TO librarian, reader
+# Note the separation of arguments with colons.
+postgresql_privs: >
+ db=library
+ privs=ALL
+ type=function
+ obj=add(int:int)
+ schema=math
+ roles=librarian,reader
+
+# GRANT librarian, reader TO alice, bob WITH ADMIN OPTION
+# Note that group role memberships apply cluster-wide and therefore are not
+# restricted to database "library" here.
+postgresql_privs: >
+ db=library
+ type=group
+ objs=librarian,reader
+ roles=alice,bob
+ admin_option=yes
+
+# GRANT ALL PRIVILEGES ON DATABASE library TO librarian
+# Note that here "db=postgres" specifies the database to connect to, not the
+# database to grant privileges on (which is specified via the "objs" param)
+postgresql_privs: >
+ db=postgres
+ privs=ALL
+ type=database
+ obj=library
+ role=librarian
+
+# GRANT ALL PRIVILEGES ON DATABASE library TO librarian
+# If objs is omitted for type "database", it defaults to the database
+# to which the connection is established
+postgresql_privs: >
+ db=library
+ privs=ALL
+ type=database
+ role=librarian
+"""
+
+try:
+ import psycopg2
+except ImportError:
+ psycopg2 = None
+
+
+class Error(Exception):
+ pass
+
+
+# We don't have functools.partial in Python < 2.5
+def partial(f, *args, **kwargs):
+ """Partial function application"""
+ def g(*g_args, **g_kwargs):
+ new_kwargs = kwargs.copy()
+ new_kwargs.update(g_kwargs)
+ return f(*(args + g_args), **g_kwargs)
+ g.f = f
+ g.args = args
+ g.kwargs = kwargs
+ return g
+
+
+class Connection(object):
+ """Wrapper around a psycopg2 connection with some convenience methods"""
+
+ def __init__(self, host, port, login, password, database):
+ self.database = database
+ self.connection = psycopg2.connect(
+ host=host, port=port, user=login,
+ password=password, database=database
+ )
+ self.cursor = self.connection.cursor()
+
+
+ def commit(self):
+ self.connection.commit()
+
+
+ def rollback(self):
+ self.connection.rollback()
+
+ @property
+ def encoding(self):
+ return self.connection.encoding
+
+
+ ### Methods for querying database objects
+
+ # PostgreSQL < 9.0 doesn't support "ALL TABLES IN SCHEMA schema"-like
+ # phrases in GRANT or REVOKE statements, therefore alternative methods are
+ # provided here.
+
+ def schema_exists(self, schema):
+ query = """SELECT count(*)
+ FROM pg_catalog.pg_namespace WHERE nspname = %s"""
+ self.cursor.execute(query, (schema,))
+ return self.cursor.fetchone()[0] > 0
+
+
+ def get_all_tables_in_schema(self, schema):
+ if not self.schema_exists(schema):
+ raise Error('Schema "%s" does not exist.' % schema)
+ query = """SELECT relname
+ FROM pg_catalog.pg_class c
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+ WHERE nspname = %s AND relkind = 'r'"""
+ self.cursor.execute(query, (schema,))
+ return [t[0] for t in self.cursor.fetchall()]
+
+
+ def get_all_sequences_in_schema(self, schema):
+ if not self.schema_exists(schema):
+ raise Error('Schema "%s" does not exist.' % schema)
+ query = """SELECT relname
+ FROM pg_catalog.pg_class c
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+ WHERE nspname = %s AND relkind = 'S'"""
+ self.cursor.execute(query, (schema,))
+ return [t[0] 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
+ # privileges, we compare the access control lists of the specified database
+ # objects before and afterwards. Python's list/string comparison should
+ # suffice for change detection, we should not actually have to parse ACLs.
+ # The same should apply to group membership information.
+
+ def get_table_acls(self, schema, tables):
+ query = """SELECT relacl
+ FROM pg_catalog.pg_class c
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+ WHERE nspname = %s AND relkind = 'r' AND relname = ANY (%s)
+ ORDER BY relname"""
+ self.cursor.execute(query, (schema, tables))
+ return [t[0] for t in self.cursor.fetchall()]
+
+
+ def get_sequence_acls(self, schema, sequences):
+ query = """SELECT relacl
+ FROM pg_catalog.pg_class c
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
+ WHERE nspname = %s AND relkind = 'S' AND relname = ANY (%s)
+ ORDER BY relname"""
+ self.cursor.execute(query, (schema, sequences))
+ return [t[0] for t in self.cursor.fetchall()]
+
+
+ def get_function_acls(self, schema, function_signatures):
+ funcnames = [f.split('(', 1)[0] for f in function_signatures]
+ query = """SELECT proacl
+ FROM pg_catalog.pg_proc p
+ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
+ WHERE nspname = %s AND proname = ANY (%s)
+ ORDER BY proname, proargtypes"""
+ self.cursor.execute(query, (schema, funcnames))
+ return [t[0] for t in self.cursor.fetchall()]
+
+
+ def get_schema_acls(self, schemas):
+ query = """SELECT nspacl FROM pg_catalog.pg_namespace
+ WHERE nspname = ANY (%s) ORDER BY nspname"""
+ self.cursor.execute(query, (schemas,))
+ return [t[0] for t in self.cursor.fetchall()]
+
+
+ def get_language_acls(self, languages):
+ query = """SELECT lanacl FROM pg_catalog.pg_language
+ WHERE lanname = ANY (%s) ORDER BY lanname"""
+ self.cursor.execute(query, (languages,))
+ return [t[0] for t in self.cursor.fetchall()]
+
+
+ def get_tablespace_acls(self, tablespaces):
+ query = """SELECT spcacl FROM pg_catalog.pg_tablespace
+ WHERE spcname = ANY (%s) ORDER BY spcname"""
+ self.cursor.execute(query, (tablespaces,))
+ return [t[0] for t in self.cursor.fetchall()]
+
+
+ def get_database_acls(self, databases):
+ query = """SELECT datacl FROM pg_catalog.pg_database
+ WHERE datname = ANY (%s) ORDER BY datname"""
+ self.cursor.execute(query, (databases,))
+ return [t[0] for t in self.cursor.fetchall()]
+
+
+ def get_group_memberships(self, groups):
+ query = """SELECT roleid, grantor, member, admin_option
+ FROM pg_catalog.pg_auth_members am
+ JOIN pg_catalog.pg_roles r ON r.oid = am.roleid
+ WHERE r.rolname = ANY(%s)
+ ORDER BY roleid, grantor, member"""
+ self.cursor.execute(query, (groups,))
+ return self.cursor.fetchall()
+
+
+ ### Manipulating privileges
+
+ def manipulate_privs(self, obj_type, privs, objs, roles,
+ state, grant_option, schema_qualifier=None):
+ """Manipulate database object privileges.
+
+ :param obj_type: Type of database object to grant/revoke
+ privileges for.
+ :param privs: Either a list of privileges to grant/revoke
+ or None if type is "group".
+ :param objs: List of database objects to grant/revoke
+ privileges for.
+ :param roles: Either a list of role names or "PUBLIC"
+ for the implicitly defined "PUBLIC" group
+ :param state: "present" to grant privileges, "absent" to revoke.
+ :param grant_option: Only for state "present": If True, set
+ grant/admin option. If False, revoke it.
+ If None, don't change grant option.
+ :param schema_qualifier: Some object types ("TABLE", "SEQUENCE",
+ "FUNCTION") must be qualified by schema.
+ Ignored for other Types.
+ """
+ # get_status: function to get current status
+ if obj_type == 'table':
+ 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':
+ get_status = partial(self.get_function_acls, schema_qualifier)
+ elif obj_type == 'schema':
+ get_status = self.get_schema_acls
+ elif obj_type == 'language':
+ get_status = self.get_language_acls
+ elif obj_type == 'tablespace':
+ get_status = self.get_tablespace_acls
+ elif obj_type == 'database':
+ get_status = self.get_database_acls
+ elif obj_type == 'group':
+ get_status = self.get_group_memberships
+ else:
+ raise Error('Unsupported database object type "%s".' % obj_type)
+
+ # Return False (nothing has changed) if there are no objs to work on.
+ if not objs:
+ return False
+
+ # obj_ids: quoted db object identifiers (sometimes schema-qualified)
+ if obj_type == 'function':
+ obj_ids = []
+ for obj in objs:
+ try:
+ f, args = obj.split('(', 1)
+ except:
+ raise Error('Illegal function signature: "%s".' % obj)
+ obj_ids.append('"%s"."%s"(%s' % (schema_qualifier, f, args))
+ elif obj_type in ['table', 'sequence']:
+ obj_ids = ['"%s"."%s"' % (schema_qualifier, o) for o in objs]
+ else:
+ obj_ids = ['"%s"' % o for o in objs]
+
+ # set_what: SQL-fragment specifying what to set for the target roless:
+ # Either group membership or privileges on objects of a certain type.
+ if obj_type == 'group':
+ set_what = ','.join(obj_ids)
+ else:
+ set_what = '%s ON %s %s' % (','.join(privs), obj_type,
+ ','.join(obj_ids))
+
+ # for_whom: SQL-fragment specifying for whom to set the above
+ if roles == 'PUBLIC':
+ for_whom = 'PUBLIC'
+ else:
+ for_whom = ','.join(['"%s"' % r for r in roles])
+
+ status_before = get_status(objs)
+ if state == 'present':
+ if grant_option:
+ if obj_type == 'group':
+ query = 'GRANT %s TO %s WITH ADMIN OPTION'
+ else:
+ query = 'GRANT %s TO %s WITH GRANT OPTION'
+ else:
+ query = 'GRANT %s TO %s'
+ self.cursor.execute(query % (set_what, for_whom))
+
+ # Only revoke GRANT/ADMIN OPTION if grant_option actually is False.
+ if grant_option == False:
+ if obj_type == 'group':
+ query = 'REVOKE ADMIN OPTION FOR %s FROM %s'
+ else:
+ query = 'REVOKE GRANT OPTION FOR %s FROM %s'
+ self.cursor.execute(query % (set_what, for_whom))
+ else:
+ query = 'REVOKE %s FROM %s'
+ self.cursor.execute(query % (set_what, for_whom))
+ status_after = get_status(objs)
+ return status_before != status_after
+
+
+def main():
+ module = AnsibleModule(
+ argument_spec = dict(
+ database=dict(required=True, aliases=['db']),
+ state=dict(default='present', choices=['present', 'absent']),
+ privs=dict(required=False, aliases=['priv']),
+ type=dict(default='table',
+ choices=['table',
+ 'sequence',
+ 'function',
+ 'database',
+ 'schema',
+ 'language',
+ 'tablespace',
+ 'group']),
+ objs=dict(required=False, aliases=['obj']),
+ schema=dict(required=False),
+ roles=dict(required=True, aliases=['role']),
+ grant_option=dict(required=False, type='bool',
+ aliases=['admin_option']),
+ host=dict(required=False, aliases=['login_host']),
+ port=dict(type='int', default=5432),
+ login=dict(default='postgres', aliases=['login_user']),
+ password=dict(required=False, aliases=['login_password'])
+ ),
+ supports_check_mode = True
+ )
+
+ # 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']:
+ p.schema = p.schema or 'public'
+ elif p.schema:
+ module.fail_json(msg='Argument "schema" is not allowed '
+ 'for type "%s".' % p.type)
+
+ # param "objs": default, required depends on param "type"
+ if p.type == 'database':
+ p.objs = p.objs or p.database
+ elif not p.objs:
+ module.fail_json(msg='Argument "objs" is required '
+ 'for type "%s".' % p.type)
+
+ # param "privs": allowed, required depends on param "type"
+ if p.type == 'group':
+ if p.privs:
+ module.fail_json(msg='Argument "privs" is not allowed '
+ 'for type "group".')
+ elif not p.privs:
+ module.fail_json(msg='Argument "privs" is required '
+ 'for type "%s".' % p.type)
+
+ # Connect to Database
+ if not psycopg2:
+ module.fail_json(msg='Python module "psycopg2" must be installed.')
+ try:
+ conn = Connection(p.host, p.port, p.login, p.password, p.database)
+ except psycopg2.Error, e:
+ module.fail_json(msg='Could not connect to database: %s' % e)
+
+ try:
+ # privs
+ if p.privs:
+ privs = p.privs.split(',')
+ else:
+ privs = None
+
+ # objs:
+ if p.type == 'table' and p.objs == 'ALL_IN_SCHEMA':
+ objs = conn.get_all_tables_in_schema(p.schema)
+ elif p.type == 'sequence' and p.objs == 'ALL_IN_SCHEMA':
+ objs = conn.get_all_sequences_in_schema(p.schema)
+ else:
+ objs = p.objs.split(',')
+
+ # function signatures are encoded using ':' to separate args
+ if p.type == 'function':
+ objs = [obj.replace(':', ',') for obj in objs]
+
+ # roles
+ if p.roles == 'PUBLIC':
+ roles = 'PUBLIC'
+ else:
+ roles = p.roles.split(',')
+
+ changed = conn.manipulate_privs(
+ obj_type = p.type,
+ privs = privs,
+ objs = objs,
+ roles = roles,
+ state = p.state,
+ grant_option = p.grant_option,
+ schema_qualifier=p.schema
+ )
+
+ except Error, e:
+ conn.rollback()
+ module.fail_json(msg=e.message)
+
+ except psycopg2.Error, e:
+ conn.rollback()
+ # psycopg2 errors come in connection encoding, reencode
+ msg = e.message.decode(conn.encoding).encode(errors='replace')
+ module.fail_json(msg=msg)
+
+ if module.check_mode:
+ conn.rollback()
+ else:
+ conn.commit()
+ module.exit_json(changed=changed)
+
+
+# this is magic, see lib/ansible/module_common.py
+#<>
+main()