66ffef5967
In MySQL 5.6, the root account created by default during MySQL installation has the PROXY ... WITH GRANT OPTION privilege for ''@'', that is, for all users. The mysql_user module tries to revoke this privilege, but this fails: _mysql_exceptions.ProgrammingError: (1064, "You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '''@'' FROM 'root'@'localhost'' at line 1") Quick fix: don't revoke privilege if user is root and the privilege to revoke contains PROXY.
389 lines
14 KiB
Python
389 lines
14 KiB
Python
#!/usr/bin/python
|
|
|
|
# (c) 2012, Mark Theunissen <mark.theunissen@gmail.com>
|
|
# Sponsored by Four Kitchens http://fourkitchens.com.
|
|
#
|
|
# 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 <http://www.gnu.org/licenses/>.
|
|
|
|
DOCUMENTATION = '''
|
|
---
|
|
module: mysql_user
|
|
short_description: Adds or removes a user from a MySQL database.
|
|
description:
|
|
- Adds or removes a user from a MySQL database.
|
|
version_added: "0.6"
|
|
options:
|
|
name:
|
|
description:
|
|
- name of the user (role) to add or remove
|
|
required: true
|
|
default: null
|
|
password:
|
|
description:
|
|
- set the user's password
|
|
required: false
|
|
default: null
|
|
host:
|
|
description:
|
|
- the 'host' part of the MySQL username
|
|
required: false
|
|
default: localhost
|
|
login_user:
|
|
description:
|
|
- The username used to authenticate with
|
|
required: false
|
|
default: null
|
|
login_password:
|
|
description:
|
|
- The password used to authenticate with
|
|
required: false
|
|
default: null
|
|
login_host:
|
|
description:
|
|
- Host running the database
|
|
required: false
|
|
default: localhost
|
|
login_unix_socket:
|
|
description:
|
|
- The path to a Unix domain socket for local connections
|
|
required: false
|
|
default: null
|
|
priv:
|
|
description:
|
|
- "MySQL privileges string in the format: C(db.table:priv1,priv2)"
|
|
required: false
|
|
default: null
|
|
state:
|
|
description:
|
|
- The database state
|
|
required: false
|
|
default: present
|
|
choices: [ "present", "absent" ]
|
|
examples:
|
|
- code: "mysql_user: name=bob password=12345 priv=*.*:ALL state=present"
|
|
description: Create database user with name 'bob' and password '12345' with all database privileges
|
|
- code: "mysql_user: login_user=root login_password=123456 name=sally state=absent"
|
|
description: Ensure no user named 'sally' exists, also passing in the auth credentials.
|
|
- code: mydb.*:INSERT,UPDATE/anotherdb.*:SELECT/yetanotherdb.*:ALL
|
|
description: Example privileges string format
|
|
- code: "mysql_user: name=root password=abc123 login_unix_socket=/var/run/mysqld/mysqld.sock"
|
|
description: Example using login_unix_socket to connect to server
|
|
notes:
|
|
- Requires the MySQLdb Python package on the remote host. For Ubuntu, this
|
|
is as easy as apt-get install python-mysqldb.
|
|
- Both C(login_password) and C(login_username) are required when you are
|
|
passing credentials. If none are present, the module will attempt to read
|
|
the credentials from C(~/.my.cnf), and finally fall back to using the MySQL
|
|
default login of 'root' with no password.
|
|
- "MySQL server installs with default login_user of 'root' and no password. To secure this user
|
|
as part of an idempotent playbook, you must create at least two tasks: the first must change the root user's password,
|
|
without providing any login_user/login_password details. The second must drop a ~/.my.cnf file containing
|
|
the new root credentials. Subsequent runs of the playbook will then succeed by reading the new credentials from
|
|
the file."
|
|
|
|
requirements: [ "ConfigParser", "MySQLdb" ]
|
|
author: Mark Theunissen
|
|
'''
|
|
|
|
EXAMPLES = """
|
|
# Example .my.cnf file for setting the root password
|
|
# Note: don't use quotes around the password, because the mysql_user module
|
|
# will include them in the password but the mysql client will not
|
|
[client]
|
|
user=root
|
|
password=n<_665{vS43y
|
|
"""
|
|
|
|
import ConfigParser
|
|
import getpass
|
|
try:
|
|
import MySQLdb
|
|
except ImportError:
|
|
mysqldb_found = False
|
|
else:
|
|
mysqldb_found = True
|
|
|
|
# ===========================================
|
|
# MySQL module specific support methods.
|
|
#
|
|
|
|
def user_exists(cursor, user, host):
|
|
cursor.execute("SELECT count(*) FROM user WHERE user = %s AND host = %s", (user,host))
|
|
count = cursor.fetchone()
|
|
return count[0] > 0
|
|
|
|
def user_add(cursor, user, host, password, new_priv):
|
|
cursor.execute("CREATE USER %s@%s IDENTIFIED BY %s", (user,host,password))
|
|
if new_priv is not None:
|
|
for db_table, priv in new_priv.iteritems():
|
|
privileges_grant(cursor, user,host,db_table,priv)
|
|
return True
|
|
|
|
def user_mod(cursor, user, host, password, new_priv):
|
|
changed = False
|
|
|
|
# Handle passwords.
|
|
if password is not None:
|
|
cursor.execute("SELECT password FROM user WHERE user = %s AND host = %s", (user,host))
|
|
current_pass_hash = cursor.fetchone()
|
|
cursor.execute("SELECT PASSWORD(%s)", (password,))
|
|
new_pass_hash = cursor.fetchone()
|
|
if current_pass_hash[0] != new_pass_hash[0]:
|
|
cursor.execute("SET PASSWORD FOR %s@%s = PASSWORD(%s)", (user,host,password))
|
|
changed = True
|
|
|
|
# Handle privileges.
|
|
if new_priv is not None:
|
|
curr_priv = privileges_get(cursor, user,host)
|
|
|
|
# If the user has privileges on a db.table that doesn't appear at all in
|
|
# the new specification, then revoke all privileges on it.
|
|
for db_table, priv in curr_priv.iteritems():
|
|
if db_table not in new_priv:
|
|
if user != "root" and "PROXY" not in priv:
|
|
privileges_revoke(cursor, user,host,db_table)
|
|
changed = True
|
|
|
|
# If the user doesn't currently have any privileges on a db.table, then
|
|
# we can perform a straight grant operation.
|
|
for db_table, priv in new_priv.iteritems():
|
|
if db_table not in curr_priv:
|
|
privileges_grant(cursor, user,host,db_table,priv)
|
|
changed = True
|
|
|
|
# If the db.table specification exists in both the user's current privileges
|
|
# and in the new privileges, then we need to see if there's a difference.
|
|
db_table_intersect = set(new_priv.keys()) & set(curr_priv.keys())
|
|
for db_table in db_table_intersect:
|
|
priv_diff = set(new_priv[db_table]) ^ set(curr_priv[db_table])
|
|
if (len(priv_diff) > 0):
|
|
privileges_revoke(cursor, user,host,db_table)
|
|
privileges_grant(cursor, user,host,db_table,new_priv[db_table])
|
|
changed = True
|
|
|
|
return changed
|
|
|
|
def user_delete(cursor, user, host):
|
|
cursor.execute("DROP USER %s@%s", (user,host))
|
|
return True
|
|
|
|
def privileges_get(cursor, user,host):
|
|
""" MySQL doesn't have a better method of getting privileges aside from the
|
|
SHOW GRANTS query syntax, which requires us to then parse the returned string.
|
|
Here's an example of the string that is returned from MySQL:
|
|
|
|
GRANT USAGE ON *.* TO 'user'@'localhost' IDENTIFIED BY 'pass';
|
|
|
|
This function makes the query and returns a dictionary containing the results.
|
|
The dictionary format is the same as that returned by privileges_unpack() below.
|
|
"""
|
|
output = {}
|
|
cursor.execute("SHOW GRANTS FOR %s@%s", (user,host))
|
|
grants = cursor.fetchall()
|
|
|
|
def pick(x):
|
|
if x == 'ALL PRIVILEGES':
|
|
return 'ALL'
|
|
else:
|
|
return x
|
|
|
|
for grant in grants:
|
|
res = re.match("GRANT (.+) ON (.+) TO '.+'@'.+'( IDENTIFIED BY PASSWORD '.+')? ?(.*)", grant[0])
|
|
if res is None:
|
|
module.fail_json(msg="unable to parse the MySQL grant string")
|
|
privileges = res.group(1).split(", ")
|
|
privileges = [ pick(x) for x in privileges]
|
|
if "WITH GRANT OPTION" in res.group(4):
|
|
privileges.append('GRANT')
|
|
db = res.group(2).replace('`', '')
|
|
output[db] = privileges
|
|
return output
|
|
|
|
def privileges_unpack(priv):
|
|
""" Take a privileges string, typically passed as a parameter, and unserialize
|
|
it into a dictionary, the same format as privileges_get() above. We have this
|
|
custom format to avoid using YAML/JSON strings inside YAML playbooks. Example
|
|
of a privileges string:
|
|
|
|
mydb.*:INSERT,UPDATE/anotherdb.*:SELECT/yetanother.*:ALL
|
|
|
|
The privilege USAGE stands for no privileges, so we add that in on *.* if it's
|
|
not specified in the string, as MySQL will always provide this by default.
|
|
"""
|
|
output = {}
|
|
for item in priv.split('/'):
|
|
pieces = item.split(':')
|
|
output[pieces[0]] = pieces[1].upper().split(',')
|
|
|
|
if '*.*' not in output:
|
|
output['*.*'] = ['USAGE']
|
|
|
|
return output
|
|
|
|
def privileges_revoke(cursor, user,host,db_table):
|
|
query = "REVOKE ALL PRIVILEGES ON %s FROM '%s'@'%s'" % (db_table,user,host)
|
|
cursor.execute(query)
|
|
query = "REVOKE GRANT OPTION ON %s FROM '%s'@'%s'" % (db_table,user,host)
|
|
try:
|
|
cursor.execute(query)
|
|
except MySQLdb.OperationalError, e:
|
|
# 1141 -> There is no such grant defined for user ... on host ...
|
|
# If this exception is raised, there is no need to revoke the GRANT privilege
|
|
if e.args[0] != 1141 or not e.args[1].startswith("There is no such grant defined for user"):
|
|
raise e
|
|
|
|
def privileges_grant(cursor, user,host,db_table,priv):
|
|
|
|
priv_string = ",".join(filter(lambda x: x != 'GRANT', priv))
|
|
query = "GRANT %s ON %s TO '%s'@'%s'" % (priv_string,db_table,user,host)
|
|
if 'GRANT' in priv:
|
|
query = query + " WITH GRANT OPTION"
|
|
cursor.execute(query)
|
|
|
|
|
|
def strip_quotes(s):
|
|
""" Remove surrounding single or double quotes
|
|
|
|
>>> print strip_quotes('hello')
|
|
hello
|
|
>>> print strip_quotes('"hello"')
|
|
hello
|
|
>>> print strip_quotes("'hello'")
|
|
hello
|
|
>>> print strip_quotes("'hello")
|
|
'hello
|
|
|
|
"""
|
|
single_quote = "'"
|
|
double_quote = '"'
|
|
|
|
if s.startswith(single_quote) and s.endswith(single_quote):
|
|
s = s.strip(single_quote)
|
|
elif s.startswith(double_quote) and s.endswith(double_quote):
|
|
s = s.strip(double_quote)
|
|
return s
|
|
|
|
|
|
def config_get(config, section, option):
|
|
""" Calls ConfigParser.get and strips quotes
|
|
|
|
See: http://dev.mysql.com/doc/refman/5.0/en/option-files.html
|
|
"""
|
|
return strip_quotes(config.get(section, option))
|
|
|
|
|
|
def load_mycnf():
|
|
config = ConfigParser.RawConfigParser()
|
|
mycnf = os.path.expanduser('~/.my.cnf')
|
|
if not os.path.exists(mycnf):
|
|
return False
|
|
try:
|
|
config.readfp(open(mycnf))
|
|
except (IOError):
|
|
return False
|
|
# We support two forms of passwords in .my.cnf, both pass= and password=,
|
|
# as these are both supported by MySQL.
|
|
try:
|
|
passwd = config_get(config, 'client', 'password')
|
|
except (ConfigParser.NoOptionError):
|
|
try:
|
|
passwd = config_get(config, 'client', 'pass')
|
|
except (ConfigParser.NoOptionError):
|
|
return False
|
|
|
|
# If .my.cnf doesn't specify a user, default to user login name
|
|
try:
|
|
user = config_get(config, 'client', 'user')
|
|
except (ConfigParser.NoOptionError):
|
|
user = getpass.getuser()
|
|
creds = dict(user=user,passwd=passwd)
|
|
return creds
|
|
|
|
# ===========================================
|
|
# Module execution.
|
|
#
|
|
|
|
def main():
|
|
module = AnsibleModule(
|
|
argument_spec = dict(
|
|
login_user=dict(default=None),
|
|
login_password=dict(default=None),
|
|
login_host=dict(default="localhost"),
|
|
login_unix_socket=dict(default=None),
|
|
user=dict(required=True, aliases=['name']),
|
|
password=dict(default=None),
|
|
host=dict(default="localhost"),
|
|
state=dict(default="present", choices=["absent", "present"]),
|
|
priv=dict(default=None),
|
|
)
|
|
)
|
|
user = module.params["user"]
|
|
password = module.params["password"]
|
|
host = module.params["host"]
|
|
state = module.params["state"]
|
|
priv = module.params["priv"]
|
|
|
|
if not mysqldb_found:
|
|
module.fail_json(msg="the python mysqldb module is required")
|
|
|
|
if priv is not None:
|
|
try:
|
|
priv = privileges_unpack(priv)
|
|
except:
|
|
module.fail_json(msg="invalid privileges string")
|
|
|
|
# Either the caller passes both a username and password with which to connect to
|
|
# mysql, or they pass neither and allow this module to read the credentials from
|
|
# ~/.my.cnf.
|
|
login_password = module.params["login_password"]
|
|
login_user = module.params["login_user"]
|
|
if login_user is None and login_password is None:
|
|
mycnf_creds = load_mycnf()
|
|
if mycnf_creds is False:
|
|
login_user = "root"
|
|
login_password = ""
|
|
else:
|
|
login_user = mycnf_creds["user"]
|
|
login_password = mycnf_creds["passwd"]
|
|
elif login_password is None or login_user is None:
|
|
module.fail_json(msg="when supplying login arguments, both login_user and login_password must be provided")
|
|
|
|
try:
|
|
if module.params["login_unix_socket"]:
|
|
db_connection = MySQLdb.connect(host=module.params["login_host"], unix_socket=module.params["login_unix_socket"], user=login_user, passwd=login_password, db="mysql")
|
|
else:
|
|
db_connection = MySQLdb.connect(host=module.params["login_host"], user=login_user, passwd=login_password, db="mysql")
|
|
cursor = db_connection.cursor()
|
|
except Exception, e:
|
|
module.fail_json(msg="unable to connect to database, check login_user and login_password are correct or ~/.my.cnf has the credentials")
|
|
|
|
if state == "present":
|
|
if user_exists(cursor, user, host):
|
|
changed = user_mod(cursor, user, host, password, priv)
|
|
else:
|
|
if password is None:
|
|
module.fail_json(msg="password parameter required when adding a user")
|
|
changed = user_add(cursor, user, host, password, priv)
|
|
elif state == "absent":
|
|
if user_exists(cursor, user, host):
|
|
changed = user_delete(cursor, user, host)
|
|
else:
|
|
changed = False
|
|
module.exit_json(changed=changed, user=user)
|
|
|
|
# this is magic, see lib/ansible/module_common.py
|
|
#<<INCLUDE_ANSIBLE_MODULE_COMMON>>
|
|
main()
|