331 lines
12 KiB
Python
331 lines
12 KiB
Python
|
# This code is part of Ansible, but is an independent component.
|
||
|
# This particular file snippet, and this file snippet only, is BSD licensed.
|
||
|
# Modules you write using this snippet, which is embedded dynamically by Ansible
|
||
|
# still belong to the author of the module, and may assign their own license
|
||
|
# to the complete work.
|
||
|
#
|
||
|
# Copyright (c), Ted Timmons <ted@timmons.me>, 2017.
|
||
|
# Most of this was originally added by other creators in the postgresql_user module.
|
||
|
# All rights reserved.
|
||
|
#
|
||
|
# Redistribution and use in source and binary forms, with or without modification,
|
||
|
# are permitted provided that the following conditions are met:
|
||
|
#
|
||
|
# * Redistributions of source code must retain the above copyright
|
||
|
# notice, this list of conditions and the following disclaimer.
|
||
|
# * Redistributions in binary form must reproduce the above copyright notice,
|
||
|
# this list of conditions and the following disclaimer in the documentation
|
||
|
# and/or other materials provided with the distribution.
|
||
|
#
|
||
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||
|
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||
|
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||
|
# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||
|
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||
|
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||
|
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||
|
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
|
||
|
# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||
|
|
||
|
psycopg2 = None # This line needs for unit tests
|
||
|
try:
|
||
|
import psycopg2
|
||
|
HAS_PSYCOPG2 = True
|
||
|
except ImportError:
|
||
|
HAS_PSYCOPG2 = False
|
||
|
|
||
|
from ansible.module_utils.basic import missing_required_lib
|
||
|
from ansible.module_utils._text import to_native
|
||
|
from ansible.module_utils.six import iteritems
|
||
|
from distutils.version import LooseVersion
|
||
|
|
||
|
|
||
|
def postgres_common_argument_spec():
|
||
|
"""
|
||
|
Return a dictionary with connection options.
|
||
|
|
||
|
The options are commonly used by most of PostgreSQL modules.
|
||
|
"""
|
||
|
return dict(
|
||
|
login_user=dict(default='postgres'),
|
||
|
login_password=dict(default='', no_log=True),
|
||
|
login_host=dict(default=''),
|
||
|
login_unix_socket=dict(default=''),
|
||
|
port=dict(type='int', default=5432, aliases=['login_port']),
|
||
|
ssl_mode=dict(default='prefer', choices=['allow', 'disable', 'prefer', 'require', 'verify-ca', 'verify-full']),
|
||
|
ca_cert=dict(aliases=['ssl_rootcert']),
|
||
|
)
|
||
|
|
||
|
|
||
|
def ensure_required_libs(module):
|
||
|
"""Check required libraries."""
|
||
|
if not HAS_PSYCOPG2:
|
||
|
module.fail_json(msg=missing_required_lib('psycopg2'))
|
||
|
|
||
|
if module.params.get('ca_cert') and LooseVersion(psycopg2.__version__) < LooseVersion('2.4.3'):
|
||
|
module.fail_json(msg='psycopg2 must be at least 2.4.3 in order to use the ca_cert parameter')
|
||
|
|
||
|
|
||
|
def connect_to_db(module, conn_params, autocommit=False, fail_on_conn=True):
|
||
|
"""Connect to a PostgreSQL database.
|
||
|
|
||
|
Return psycopg2 connection object.
|
||
|
|
||
|
Args:
|
||
|
module (AnsibleModule) -- object of ansible.module_utils.basic.AnsibleModule class
|
||
|
conn_params (dict) -- dictionary with connection parameters
|
||
|
|
||
|
Kwargs:
|
||
|
autocommit (bool) -- commit automatically (default False)
|
||
|
fail_on_conn (bool) -- fail if connection failed or just warn and return None (default True)
|
||
|
"""
|
||
|
ensure_required_libs(module)
|
||
|
|
||
|
db_connection = None
|
||
|
try:
|
||
|
db_connection = psycopg2.connect(**conn_params)
|
||
|
if autocommit:
|
||
|
if LooseVersion(psycopg2.__version__) >= LooseVersion('2.4.2'):
|
||
|
db_connection.set_session(autocommit=True)
|
||
|
else:
|
||
|
db_connection.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT)
|
||
|
|
||
|
# Switch role, if specified:
|
||
|
if module.params.get('session_role'):
|
||
|
cursor = db_connection.cursor(cursor_factory=psycopg2.extras.DictCursor)
|
||
|
|
||
|
try:
|
||
|
cursor.execute('SET ROLE "%s"' % module.params['session_role'])
|
||
|
except Exception as e:
|
||
|
module.fail_json(msg="Could not switch role: %s" % to_native(e))
|
||
|
finally:
|
||
|
cursor.close()
|
||
|
|
||
|
except TypeError as e:
|
||
|
if 'sslrootcert' in e.args[0]:
|
||
|
module.fail_json(msg='Postgresql server must be at least '
|
||
|
'version 8.4 to support sslrootcert')
|
||
|
|
||
|
if fail_on_conn:
|
||
|
module.fail_json(msg="unable to connect to database: %s" % to_native(e))
|
||
|
else:
|
||
|
module.warn("PostgreSQL server is unavailable: %s" % to_native(e))
|
||
|
db_connection = None
|
||
|
|
||
|
except Exception as e:
|
||
|
if fail_on_conn:
|
||
|
module.fail_json(msg="unable to connect to database: %s" % to_native(e))
|
||
|
else:
|
||
|
module.warn("PostgreSQL server is unavailable: %s" % to_native(e))
|
||
|
db_connection = None
|
||
|
|
||
|
return db_connection
|
||
|
|
||
|
|
||
|
def exec_sql(obj, query, query_params=None, ddl=False, add_to_executed=True, dont_exec=False):
|
||
|
"""Execute SQL.
|
||
|
|
||
|
Auxiliary function for PostgreSQL user classes.
|
||
|
|
||
|
Returns a query result if possible or True/False if ddl=True arg was passed.
|
||
|
It necessary for statements that don't return any result (like DDL queries).
|
||
|
|
||
|
Args:
|
||
|
obj (obj) -- must be an object of a user class.
|
||
|
The object must have module (AnsibleModule class object) and
|
||
|
cursor (psycopg cursor object) attributes
|
||
|
query (str) -- SQL query to execute
|
||
|
|
||
|
Kwargs:
|
||
|
query_params (dict or tuple) -- Query parameters to prevent SQL injections,
|
||
|
could be a dict or tuple
|
||
|
ddl (bool) -- must return True or False instead of rows (typical for DDL queries)
|
||
|
(default False)
|
||
|
add_to_executed (bool) -- append the query to obj.executed_queries attribute
|
||
|
dont_exec (bool) -- used with add_to_executed=True to generate a query, add it
|
||
|
to obj.executed_queries list and return True (default False)
|
||
|
"""
|
||
|
|
||
|
if dont_exec:
|
||
|
# This is usually needed to return queries in check_mode
|
||
|
# without execution
|
||
|
query = obj.cursor.mogrify(query, query_params)
|
||
|
if add_to_executed:
|
||
|
obj.executed_queries.append(query)
|
||
|
|
||
|
return True
|
||
|
|
||
|
try:
|
||
|
if query_params is not None:
|
||
|
obj.cursor.execute(query, query_params)
|
||
|
else:
|
||
|
obj.cursor.execute(query)
|
||
|
|
||
|
if add_to_executed:
|
||
|
if query_params is not None:
|
||
|
obj.executed_queries.append(obj.cursor.mogrify(query, query_params))
|
||
|
else:
|
||
|
obj.executed_queries.append(query)
|
||
|
|
||
|
if not ddl:
|
||
|
res = obj.cursor.fetchall()
|
||
|
return res
|
||
|
return True
|
||
|
except Exception as e:
|
||
|
obj.module.fail_json(msg="Cannot execute SQL '%s': %s" % (query, to_native(e)))
|
||
|
return False
|
||
|
|
||
|
|
||
|
def get_conn_params(module, params_dict, warn_db_default=True):
|
||
|
"""Get connection parameters from the passed dictionary.
|
||
|
|
||
|
Return a dictionary with parameters to connect to PostgreSQL server.
|
||
|
|
||
|
Args:
|
||
|
module (AnsibleModule) -- object of ansible.module_utils.basic.AnsibleModule class
|
||
|
params_dict (dict) -- dictionary with variables
|
||
|
|
||
|
Kwargs:
|
||
|
warn_db_default (bool) -- warn that the default DB is used (default True)
|
||
|
"""
|
||
|
# To use defaults values, keyword arguments must be absent, so
|
||
|
# check which values are empty and don't include in the return dictionary
|
||
|
params_map = {
|
||
|
"login_host": "host",
|
||
|
"login_user": "user",
|
||
|
"login_password": "password",
|
||
|
"port": "port",
|
||
|
"ssl_mode": "sslmode",
|
||
|
"ca_cert": "sslrootcert"
|
||
|
}
|
||
|
|
||
|
# Might be different in the modules:
|
||
|
if params_dict.get('db'):
|
||
|
params_map['db'] = 'database'
|
||
|
elif params_dict.get('database'):
|
||
|
params_map['database'] = 'database'
|
||
|
elif params_dict.get('login_db'):
|
||
|
params_map['login_db'] = 'database'
|
||
|
else:
|
||
|
if warn_db_default:
|
||
|
module.warn('Database name has not been passed, '
|
||
|
'used default database to connect to.')
|
||
|
|
||
|
kw = dict((params_map[k], v) for (k, v) in iteritems(params_dict)
|
||
|
if k in params_map and v != '' and v is not None)
|
||
|
|
||
|
# If a login_unix_socket is specified, incorporate it here.
|
||
|
is_localhost = "host" not in kw or kw["host"] is None or kw["host"] == "localhost"
|
||
|
if is_localhost and params_dict["login_unix_socket"] != "":
|
||
|
kw["host"] = params_dict["login_unix_socket"]
|
||
|
|
||
|
return kw
|
||
|
|
||
|
|
||
|
class PgMembership(object):
|
||
|
def __init__(self, module, cursor, groups, target_roles, fail_on_role=True):
|
||
|
self.module = module
|
||
|
self.cursor = cursor
|
||
|
self.target_roles = [r.strip() for r in target_roles]
|
||
|
self.groups = [r.strip() for r in groups]
|
||
|
self.executed_queries = []
|
||
|
self.granted = {}
|
||
|
self.revoked = {}
|
||
|
self.fail_on_role = fail_on_role
|
||
|
self.non_existent_roles = []
|
||
|
self.changed = False
|
||
|
self.__check_roles_exist()
|
||
|
|
||
|
def grant(self):
|
||
|
for group in self.groups:
|
||
|
self.granted[group] = []
|
||
|
|
||
|
for role in self.target_roles:
|
||
|
# If role is in a group now, pass:
|
||
|
if self.__check_membership(group, role):
|
||
|
continue
|
||
|
|
||
|
query = 'GRANT "%s" TO "%s"' % (group, role)
|
||
|
self.changed = exec_sql(self, query, ddl=True)
|
||
|
|
||
|
if self.changed:
|
||
|
self.granted[group].append(role)
|
||
|
|
||
|
return self.changed
|
||
|
|
||
|
def revoke(self):
|
||
|
for group in self.groups:
|
||
|
self.revoked[group] = []
|
||
|
|
||
|
for role in self.target_roles:
|
||
|
# If role is not in a group now, pass:
|
||
|
if not self.__check_membership(group, role):
|
||
|
continue
|
||
|
|
||
|
query = 'REVOKE "%s" FROM "%s"' % (group, role)
|
||
|
self.changed = exec_sql(self, query, ddl=True)
|
||
|
|
||
|
if self.changed:
|
||
|
self.revoked[group].append(role)
|
||
|
|
||
|
return self.changed
|
||
|
|
||
|
def __check_membership(self, src_role, dst_role):
|
||
|
query = ("SELECT ARRAY(SELECT b.rolname FROM "
|
||
|
"pg_catalog.pg_auth_members m "
|
||
|
"JOIN pg_catalog.pg_roles b ON (m.roleid = b.oid) "
|
||
|
"WHERE m.member = r.oid) "
|
||
|
"FROM pg_catalog.pg_roles r "
|
||
|
"WHERE r.rolname = %(dst_role)s")
|
||
|
|
||
|
res = exec_sql(self, query, query_params={'dst_role': dst_role}, add_to_executed=False)
|
||
|
membership = []
|
||
|
if res:
|
||
|
membership = res[0][0]
|
||
|
|
||
|
if not membership:
|
||
|
return False
|
||
|
|
||
|
if src_role in membership:
|
||
|
return True
|
||
|
|
||
|
return False
|
||
|
|
||
|
def __check_roles_exist(self):
|
||
|
existent_groups = self.__roles_exist(self.groups)
|
||
|
existent_roles = self.__roles_exist(self.target_roles)
|
||
|
|
||
|
for group in self.groups:
|
||
|
if group not in existent_groups:
|
||
|
if self.fail_on_role:
|
||
|
self.module.fail_json(msg="Role %s does not exist" % group)
|
||
|
else:
|
||
|
self.module.warn("Role %s does not exist, pass" % group)
|
||
|
self.non_existent_roles.append(group)
|
||
|
|
||
|
for role in self.target_roles:
|
||
|
if role not in existent_roles:
|
||
|
if self.fail_on_role:
|
||
|
self.module.fail_json(msg="Role %s does not exist" % role)
|
||
|
else:
|
||
|
self.module.warn("Role %s does not exist, pass" % role)
|
||
|
|
||
|
if role not in self.groups:
|
||
|
self.non_existent_roles.append(role)
|
||
|
|
||
|
else:
|
||
|
if self.fail_on_role:
|
||
|
self.module.exit_json(msg="Role role '%s' is a member of role '%s'" % (role, role))
|
||
|
else:
|
||
|
self.module.warn("Role role '%s' is a member of role '%s', pass" % (role, role))
|
||
|
|
||
|
# Update role lists, excluding non existent roles:
|
||
|
self.groups = [g for g in self.groups if g not in self.non_existent_roles]
|
||
|
|
||
|
self.target_roles = [r for r in self.target_roles if r not in self.non_existent_roles]
|
||
|
|
||
|
def __roles_exist(self, roles):
|
||
|
tmp = ["'" + x + "'" for x in roles]
|
||
|
query = "SELECT rolname FROM pg_roles WHERE rolname IN (%s)" % ','.join(tmp)
|
||
|
return [x[0] for x in exec_sql(self, query, add_to_executed=False)]
|