mysql_user 14.4 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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
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:
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_unix_socket:
    description:
      - The path to a Unix domain socket for local connections
    required: false
    default: null
64 65
  priv:
    description:
66
      - "MySQL privileges string in the format: C(db.table:priv1,priv2)"
67 68 69 70
    required: false
    default: null
  state:
    description:
71 72
      - Whether the user should exist.  When C(absent), removes
        the user.
73 74 75
    required: false
    default: present
    choices: [ "present", "absent" ]
76 77 78 79 80
  check_implicit_admin:
    description:
      - Check if mysql allows login as root/nopassword before trying supplied credentials.
    required: false
    default: false
81
    version_added: "1.3"
82
notes:
83 84 85 86 87 88
   - 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.
89
   - "MySQL server installs with default login_user of 'root' and no password. To secure this user
90 91 92
     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
93
     the file."
94

95
requirements: [ "ConfigParser", "MySQLdb" ]
96 97 98
author: Mark Theunissen
'''

99
EXAMPLES = """
100 101 102 103 104 105 106 107 108 109 110 111
# Create database user with name 'bob' and password '12345' with all database privileges
- mysql_user: name=bob password=12345 priv=*.*:ALL state=present

# 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

112 113 114
# 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
115

116 117 118 119 120
[client]
user=root
password=n<_665{vS43y
"""

121
import ConfigParser
122
import getpass
Mark Theunissen committed
123
try:
124
    import MySQLdb
Mark Theunissen committed
125
except ImportError:
126 127 128
    mysqldb_found = False
else:
    mysqldb_found = True
Mark Theunissen committed
129 130 131 132 133

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

134
def user_exists(cursor, user, host):
Mark Theunissen committed
135 136 137 138
    cursor.execute("SELECT count(*) FROM user WHERE user = %s AND host = %s", (user,host))
    count = cursor.fetchone()
    return count[0] > 0

139 140
def user_add(cursor, user, host, password, new_priv):
    cursor.execute("CREATE USER %s@%s IDENTIFIED BY %s", (user,host,password))
Mark Theunissen committed
141 142
    if new_priv is not None:
        for db_table, priv in new_priv.iteritems():
143
            privileges_grant(cursor, user,host,db_table,priv)
Mark Theunissen committed
144 145
    return True

146
def user_mod(cursor, user, host, password, new_priv):
Mark Theunissen committed
147
    changed = False
148
    grant_option = False
Mark Theunissen committed
149 150

    # Handle passwords.
151
    if password is not None:
Mark Theunissen committed
152 153
        cursor.execute("SELECT password FROM user WHERE user = %s AND host = %s", (user,host))
        current_pass_hash = cursor.fetchone()
154
        cursor.execute("SELECT PASSWORD(%s)", (password,))
Mark Theunissen committed
155 156
        new_pass_hash = cursor.fetchone()
        if current_pass_hash[0] != new_pass_hash[0]:
157
            cursor.execute("SET PASSWORD FOR %s@%s = PASSWORD(%s)", (user,host,password))
Mark Theunissen committed
158 159 160 161
            changed = True

    # Handle privileges.
    if new_priv is not None:
162
        curr_priv = privileges_get(cursor, user,host)
Mark Theunissen committed
163 164 165 166

        # 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():
167 168 169
            # If the user has the GRANT OPTION on a db.table, revoke it first.
            if "GRANT" in priv:
                grant_option = True
Mark Theunissen committed
170
            if db_table not in new_priv:
171
                if user != "root" and "PROXY" not in priv:
172
                    privileges_revoke(cursor, user,host,db_table,grant_option)
173
                    changed = True
Mark Theunissen committed
174 175 176 177 178

        # 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:
179
                privileges_grant(cursor, user,host,db_table,priv)
Mark Theunissen committed
180 181 182 183 184 185 186 187
                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):
188
                privileges_revoke(cursor, user,host,db_table,grant_option)
189
                privileges_grant(cursor, user,host,db_table,new_priv[db_table])
Mark Theunissen committed
190 191 192 193
                changed = True

    return changed

194
def user_delete(cursor, user, host):
Mark Theunissen committed
195 196 197
    cursor.execute("DROP USER %s@%s", (user,host))
    return True

198
def privileges_get(cursor, user,host):
Mark Theunissen committed
199 200 201 202 203 204 205 206 207 208 209 210
    """ 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()
211 212 213 214 215 216 217

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

Mark Theunissen committed
218
    for grant in grants:
219
        res = re.match("GRANT (.+) ON (.+) TO '.+'@'.+'( IDENTIFIED BY PASSWORD '.+')? ?(.*)", grant[0])
Mark Theunissen committed
220
        if res is None:
221
            module.fail_json(msg="unable to parse the MySQL grant string")
Mark Theunissen committed
222
        privileges = res.group(1).split(", ")
223
        privileges = [ pick(x) for x in privileges]
224
        if "WITH GRANT OPTION" in res.group(4):
Michael DeHaan committed
225
            privileges.append('GRANT')
Mark Theunissen committed
226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250
        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

251 252 253 254
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
255 256 257
    query = "REVOKE ALL PRIVILEGES ON %s FROM '%s'@'%s'" % (db_table,user,host)
    cursor.execute(query)

258
def privileges_grant(cursor, user,host,db_table,priv):
259 260

    priv_string = ",".join(filter(lambda x: x != 'GRANT', priv))
Mark Theunissen committed
261
    query = "GRANT %s ON %s TO '%s'@'%s'" % (priv_string,db_table,user,host)
262
    if 'GRANT' in priv:
Michael DeHaan committed
263
        query = query + " WITH GRANT OPTION"
Mark Theunissen committed
264 265
    cursor.execute(query)

266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297

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


298 299 300
def load_mycnf():
    config = ConfigParser.RawConfigParser()
    mycnf = os.path.expanduser('~/.my.cnf')
301 302
    if not os.path.exists(mycnf):
        return False
303
    try:
304
        config.readfp(open(mycnf))
305 306 307 308 309
    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:
310
        passwd = config_get(config, 'client', 'password')
311 312
    except (ConfigParser.NoOptionError):
        try:
313
            passwd = config_get(config, 'client', 'pass')
314 315
        except (ConfigParser.NoOptionError):
            return False
316 317

    # If .my.cnf doesn't specify a user, default to user login name
318
    try:
319
        user = config_get(config, 'client', 'user')
320
    except (ConfigParser.NoOptionError):
321 322
        user = getpass.getuser()
    creds = dict(user=user,passwd=passwd)
323 324
    return creds

325 326 327 328 329 330 331
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:
        db_connection = MySQLdb.connect(host=module.params["login_host"], user=login_user, passwd=login_password, db="mysql")
    return db_connection.cursor()

Mark Theunissen committed
332 333 334 335
# ===========================================
# Module execution.
#

336 337 338
def main():
    module = AnsibleModule(
        argument_spec = dict(
339 340 341
            login_user=dict(default=None),
            login_password=dict(default=None),
            login_host=dict(default="localhost"),
342
            login_unix_socket=dict(default=None),
343
            user=dict(required=True, aliases=['name']),
344
            password=dict(default=None),
345 346 347
            host=dict(default="localhost"),
            state=dict(default="present", choices=["absent", "present"]),
            priv=dict(default=None),
348
            check_implicit_admin=dict(default=False),
349 350 351
        )
    )
    user = module.params["user"]
352
    password = module.params["password"]
353 354 355
    host = module.params["host"]
    state = module.params["state"]
    priv = module.params["priv"]
356
    check_implicit_admin = module.params['check_implicit_admin']
357 358 359 360 361 362 363 364 365

    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
366

367 368 369
    # 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.
370 371 372
    login_password = module.params["login_password"]
    login_user = module.params["login_user"]
    if login_user is None and login_password is None:
373 374
        mycnf_creds = load_mycnf()
        if mycnf_creds is False:
375 376
            login_user = "root"
            login_password = ""
377
        else:
378
            login_user = mycnf_creds["user"]
379
            login_password = mycnf_creds["passwd"]
380 381
    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")
382

383
    cursor = None
Mark Theunissen committed
384
    try:
385 386 387 388 389 390 391 392
        if check_implicit_admin:
            try:
                cursor = connect(module, 'root', '')
            except:
                pass

        if not cursor:
            cursor = connect(module, login_user, login_password)
393
    except Exception, e:
394
        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
395 396

    if state == "present":
397
        if user_exists(cursor, user, host):
398
            changed = user_mod(cursor, user, host, password, priv)
Mark Theunissen committed
399
        else:
400 401 402
            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
403
    elif state == "absent":
404 405
        if user_exists(cursor, user, host):
            changed = user_delete(cursor, user, host)
Mark Theunissen committed
406 407
        else:
            changed = False
408
    module.exit_json(changed=changed, user=user)
Mark Theunissen committed
409

410 411 412
# this is magic, see lib/ansible/module_common.py
#<<INCLUDE_ANSIBLE_MODULE_COMMON>>
main()