mysql_user 16.2 KB
Newer Older
Mark Theunissen committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
#!/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/>.

21 22 23 24 25 26
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.
27
version_added: "0.6"
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
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:
51
      - The password used to authenticate with
52 53 54 55 56 57 58
    required: false
    default: null
  login_host:
    description:
      - Host running the database
    required: false
    default: localhost
59 60 61 62 63
  login_port:
    description:
      - Port of the MySQL server
    required: false
    default: 3306
64
    version_added: '1.4'
65 66 67 68 69
  login_unix_socket:
    description:
      - The path to a Unix domain socket for local connections
    required: false
    default: null
70 71
  priv:
    description:
72
      - "MySQL privileges string in the format: C(db.table:priv1,priv2)"
73 74
    required: false
    default: null
75 76 77 78 79 80 81 82
  append_privs:
    description:
      - Append the privileges defined by priv to the existing ones for this
        user instead of overwriting existing ones.
    required: false
    choices: [ "yes", "no" ]
    default: "no"
    version_added: "1.4"
83 84
  state:
    description:
85 86
      - Whether the user should exist.  When C(absent), removes
        the user.
87 88 89
    required: false
    default: present
    choices: [ "present", "absent" ]
90 91 92 93 94
  check_implicit_admin:
    description:
      - Check if mysql allows login as root/nopassword before trying supplied credentials.
    required: false
    default: false
95
    version_added: "1.3"
96
notes:
97 98 99 100 101 102
   - 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.
103
   - "MySQL server installs with default login_user of 'root' and no password. To secure this user
104 105 106
     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
107
     the file."
108

109
requirements: [ "ConfigParser", "MySQLdb" ]
110 111 112
author: Mark Theunissen
'''

113
EXAMPLES = """
114 115 116
# Create database user with name 'bob' and password '12345' with all database privileges
- mysql_user: name=bob password=12345 priv=*.*:ALL state=present

117 118 119
# Creates database user 'bob' and password '12345' with all database privileges and 'WITH GRANT OPTION'
- mysql_user: name=bob password=12345 priv=*.*:ALL,GRANT state=present

120 121 122 123 124 125 126 127 128
# Ensure no user named 'sally' exists, also passing in the auth credentials.
- mysql_user: login_user=root login_password=123456 name=sally state=absent

# Example privileges string format
mydb.*:INSERT,UPDATE/anotherdb.*:SELECT/yetanotherdb.*:ALL

# Example using login_unix_socket to connect to server
- mysql_user: name=root password=abc123 login_unix_socket=/var/run/mysqld/mysqld.sock

129 130 131
# 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
132

133 134 135 136 137
[client]
user=root
password=n<_665{vS43y
"""

138
import ConfigParser
139
import getpass
140
import tempfile
Mark Theunissen committed
141
try:
142
    import MySQLdb
Mark Theunissen committed
143
except ImportError:
144 145 146
    mysqldb_found = False
else:
    mysqldb_found = True
Mark Theunissen committed
147 148 149 150 151

# ===========================================
# MySQL module specific support methods.
#

152
def user_exists(cursor, user, host):
Mark Theunissen committed
153 154 155 156
    cursor.execute("SELECT count(*) FROM user WHERE user = %s AND host = %s", (user,host))
    count = cursor.fetchone()
    return count[0] > 0

157 158
def user_add(cursor, user, host, password, new_priv):
    cursor.execute("CREATE USER %s@%s IDENTIFIED BY %s", (user,host,password))
Mark Theunissen committed
159 160
    if new_priv is not None:
        for db_table, priv in new_priv.iteritems():
161
            privileges_grant(cursor, user,host,db_table,priv)
Mark Theunissen committed
162 163
    return True

164
def user_mod(cursor, user, host, password, new_priv, append_privs):
Mark Theunissen committed
165
    changed = False
166
    grant_option = False
Mark Theunissen committed
167 168

    # Handle passwords.
169
    if password is not None:
Mark Theunissen committed
170 171
        cursor.execute("SELECT password FROM user WHERE user = %s AND host = %s", (user,host))
        current_pass_hash = cursor.fetchone()
172
        cursor.execute("SELECT PASSWORD(%s)", (password,))
Mark Theunissen committed
173 174
        new_pass_hash = cursor.fetchone()
        if current_pass_hash[0] != new_pass_hash[0]:
175
            cursor.execute("SET PASSWORD FOR %s@%s = PASSWORD(%s)", (user,host,password))
Mark Theunissen committed
176 177 178 179
            changed = True

    # Handle privileges.
    if new_priv is not None:
180
        curr_priv = privileges_get(cursor, user,host)
Mark Theunissen committed
181 182 183 184

        # 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():
185 186 187
            # If the user has the GRANT OPTION on a db.table, revoke it first.
            if "GRANT" in priv:
                grant_option = True
Mark Theunissen committed
188
            if db_table not in new_priv:
189
                if user != "root" and "PROXY" not in priv and not append_privs:
190
                    privileges_revoke(cursor, user,host,db_table,grant_option)
191
                    changed = True
Mark Theunissen committed
192 193 194 195 196

        # 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:
197
                privileges_grant(cursor, user,host,db_table,priv)
Mark Theunissen committed
198 199 200 201 202 203 204 205
                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):
206
                privileges_revoke(cursor, user,host,db_table,grant_option)
207
                privileges_grant(cursor, user,host,db_table,new_priv[db_table])
Mark Theunissen committed
208 209 210 211
                changed = True

    return changed

212
def user_delete(cursor, user, host):
Mark Theunissen committed
213 214 215
    cursor.execute("DROP USER %s@%s", (user,host))
    return True

216
def privileges_get(cursor, user,host):
Mark Theunissen committed
217 218 219 220 221 222 223 224 225 226 227 228
    """ 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()
229 230 231 232 233 234 235

    def pick(x):
        if x == 'ALL PRIVILEGES':
            return 'ALL'
        else:
            return x

Mark Theunissen committed
236
    for grant in grants:
237
        res = re.match("GRANT (.+) ON (.+) TO '.+'@'.+'( IDENTIFIED BY PASSWORD '.+')? ?(.*)", grant[0])
Mark Theunissen committed
238
        if res is None:
239
            module.fail_json(msg="unable to parse the MySQL grant string")
Mark Theunissen committed
240
        privileges = res.group(1).split(", ")
241
        privileges = [ pick(x) for x in privileges]
242
        if "WITH GRANT OPTION" in res.group(4):
Michael DeHaan committed
243
            privileges.append('GRANT')
244
        db = res.group(2)
Mark Theunissen committed
245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261
        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(':')
262
        if '.' in pieces[0]:
263 264 265 266 267
            pieces[0] = pieces[0].split('.')
            for idx, piece in enumerate(pieces):
                if pieces[0][idx] != "*":
                    pieces[0][idx] = "`" + pieces[0][idx] + "`"
            pieces[0] = '.'.join(pieces[0])
268

Mark Theunissen committed
269 270 271 272 273 274 275
        output[pieces[0]] = pieces[1].upper().split(',')

    if '*.*' not in output:
        output['*.*'] = ['USAGE']

    return output

276 277 278 279
def privileges_revoke(cursor, user,host,db_table,grant_option):
    if grant_option:
        query = "REVOKE GRANT OPTION ON %s FROM '%s'@'%s'" % (db_table,user,host)
        cursor.execute(query)
Mark Theunissen committed
280 281 282
    query = "REVOKE ALL PRIVILEGES ON %s FROM '%s'@'%s'" % (db_table,user,host)
    cursor.execute(query)

283
def privileges_grant(cursor, user,host,db_table,priv):
284 285

    priv_string = ",".join(filter(lambda x: x != 'GRANT', priv))
Mark Theunissen committed
286
    query = "GRANT %s ON %s TO '%s'@'%s'" % (priv_string,db_table,user,host)
287
    if 'GRANT' in priv:
Michael DeHaan committed
288
        query = query + " WITH GRANT OPTION"
Mark Theunissen committed
289 290
    cursor.execute(query)

291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322

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))


323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348
def _safe_cnf_load(config, path):

    data = {'user':'', 'password':''}

    # read in user/pass
    f = open(path, 'r')
    for line in f.readlines():
        line = line.strip()
        if line.startswith('user='):
            data['user'] = line.split('=', 1)[1].strip()
        if line.startswith('password=') or line.startswith('pass='):
            data['password'] = line.split('=', 1)[1].strip()
    f.close()

    # write out a new cnf file with only user/pass   
    fh, newpath = tempfile.mkstemp(prefix=path + '.')
    f = open(newpath, 'wb')
    f.write('[client]\n')
    f.write('user=%s\n' % data['user'])
    f.write('password=%s\n' % data['password'])
    f.close()

    config.readfp(open(newpath))
    os.remove(newpath)
    return config

349 350 351
def load_mycnf():
    config = ConfigParser.RawConfigParser()
    mycnf = os.path.expanduser('~/.my.cnf')
352 353
    if not os.path.exists(mycnf):
        return False
354
    try:
355
        config.readfp(open(mycnf))
356 357
    except (IOError):
        return False
358 359 360
    except:
        config = _safe_cnf_load(config, mycnf)

361 362 363
    # We support two forms of passwords in .my.cnf, both pass= and password=,
    # as these are both supported by MySQL.
    try:
364
        passwd = config_get(config, 'client', 'password')
365 366
    except (ConfigParser.NoOptionError):
        try:
367
            passwd = config_get(config, 'client', 'pass')
368 369
        except (ConfigParser.NoOptionError):
            return False
370 371

    # If .my.cnf doesn't specify a user, default to user login name
372
    try:
373
        user = config_get(config, 'client', 'user')
374
    except (ConfigParser.NoOptionError):
375 376
        user = getpass.getuser()
    creds = dict(user=user,passwd=passwd)
377 378
    return creds

379 380 381 382
def connect(module, login_user, login_password):
    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:
383
        db_connection = MySQLdb.connect(host=module.params["login_host"], port=int(module.params["login_port"]), user=login_user, passwd=login_password, db="mysql")
384 385
    return db_connection.cursor()

Mark Theunissen committed
386 387 388 389
# ===========================================
# Module execution.
#

390 391 392
def main():
    module = AnsibleModule(
        argument_spec = dict(
393 394 395
            login_user=dict(default=None),
            login_password=dict(default=None),
            login_host=dict(default="localhost"),
396
            login_port=dict(default="3306"),
397
            login_unix_socket=dict(default=None),
398
            user=dict(required=True, aliases=['name']),
399
            password=dict(default=None),
400 401 402
            host=dict(default="localhost"),
            state=dict(default="present", choices=["absent", "present"]),
            priv=dict(default=None),
403
            append_privs=dict(type="bool", default="no"),
404
            check_implicit_admin=dict(default=False),
405 406 407
        )
    )
    user = module.params["user"]
408
    password = module.params["password"]
409 410 411
    host = module.params["host"]
    state = module.params["state"]
    priv = module.params["priv"]
412
    check_implicit_admin = module.params['check_implicit_admin']
413
    append_privs = module.boolean(module.params["append_privs"])
414 415 416 417 418 419 420 421 422

    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")
Mark Theunissen committed
423

424 425 426
    # 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.
427 428 429
    login_password = module.params["login_password"]
    login_user = module.params["login_user"]
    if login_user is None and login_password is None:
430 431
        mycnf_creds = load_mycnf()
        if mycnf_creds is False:
432 433
            login_user = "root"
            login_password = ""
434
        else:
435
            login_user = mycnf_creds["user"]
436
            login_password = mycnf_creds["passwd"]
437 438
    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")
439

440
    cursor = None
Mark Theunissen committed
441
    try:
442 443 444 445 446 447 448 449
        if check_implicit_admin:
            try:
                cursor = connect(module, 'root', '')
            except:
                pass

        if not cursor:
            cursor = connect(module, login_user, login_password)
450
    except Exception, e:
451
        module.fail_json(msg="unable to connect to database, check login_user and login_password are correct or ~/.my.cnf has the credentials")
Mark Theunissen committed
452 453

    if state == "present":
454
        if user_exists(cursor, user, host):
455
            changed = user_mod(cursor, user, host, password, priv, append_privs)
Mark Theunissen committed
456
        else:
457 458 459
            if password is None:
                module.fail_json(msg="password parameter required when adding a user")
            changed = user_add(cursor, user, host, password, priv)
Mark Theunissen committed
460
    elif state == "absent":
461 462
        if user_exists(cursor, user, host):
            changed = user_delete(cursor, user, host)
Mark Theunissen committed
463 464
        else:
            changed = False
465
    module.exit_json(changed=changed, user=user)
Mark Theunissen committed
466

467
# import module snippets
468
from ansible.module_utils.basic import *
469
main()