mysql_user 11.3 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 59 60
    required: false
    default: null
  login_host:
    description:
      - Host running the database
    required: false
    default: localhost
  priv:
    description:
61
      - "MySQL privileges string in the format: C(db.table:priv1,priv2)"
62 63 64 65 66 67 68 69 70
    required: false
    default: null
  state:
    description:
      - The database state
    required: false
    default: present
    choices: [ "present", "absent" ]
examples:
71
   - code: "mysql_user: name=bob password=12345 priv=*.*:ALL state=present"
72
     description: Create database user with name 'bob' and password '12345' with all database privileges
73
   - code: "mysql_user: login_user=root login_password=123456 name=sally state=absent"
74 75 76 77
     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
notes:
78 79 80 81 82 83 84
   - 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.
requirements: [ "ConfigParser", "MySQLdb" ]
85 86 87
author: Mark Theunissen
'''

88
import ConfigParser
Mark Theunissen committed
89
try:
90
    import MySQLdb
Mark Theunissen committed
91
except ImportError:
92 93 94
    mysqldb_found = False
else:
    mysqldb_found = True
Mark Theunissen committed
95 96 97 98 99

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

100
def user_exists(cursor, user, host):
Mark Theunissen committed
101 102 103 104
    cursor.execute("SELECT count(*) FROM user WHERE user = %s AND host = %s", (user,host))
    count = cursor.fetchone()
    return count[0] > 0

105 106
def user_add(cursor, user, host, password, new_priv):
    cursor.execute("CREATE USER %s@%s IDENTIFIED BY %s", (user,host,password))
Mark Theunissen committed
107 108
    if new_priv is not None:
        for db_table, priv in new_priv.iteritems():
109
            privileges_grant(cursor, user,host,db_table,priv)
Mark Theunissen committed
110 111
    return True

112
def user_mod(cursor, user, host, password, new_priv):
Mark Theunissen committed
113 114 115
    changed = False

    # Handle passwords.
116
    if password is not None:
Mark Theunissen committed
117 118
        cursor.execute("SELECT password FROM user WHERE user = %s AND host = %s", (user,host))
        current_pass_hash = cursor.fetchone()
119
        cursor.execute("SELECT PASSWORD(%s)", (password,))
Mark Theunissen committed
120 121
        new_pass_hash = cursor.fetchone()
        if current_pass_hash[0] != new_pass_hash[0]:
122
            cursor.execute("SET PASSWORD FOR %s@%s = PASSWORD(%s)", (user,host,password))
Mark Theunissen committed
123 124 125 126
            changed = True

    # Handle privileges.
    if new_priv is not None:
127
        curr_priv = privileges_get(cursor, user,host)
Mark Theunissen committed
128 129 130 131 132

        # 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:
133
                privileges_revoke(cursor, user,host,db_table)
Mark Theunissen committed
134 135 136 137 138 139
                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:
140
                privileges_grant(cursor, user,host,db_table,priv)
Mark Theunissen committed
141 142 143 144 145 146 147 148
                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):
149 150
                privileges_revoke(cursor, user,host,db_table)
                privileges_grant(cursor, user,host,db_table,new_priv[db_table])
Mark Theunissen committed
151 152 153 154
                changed = True

    return changed

155
def user_delete(cursor, user, host):
Mark Theunissen committed
156 157 158
    cursor.execute("DROP USER %s@%s", (user,host))
    return True

159
def privileges_get(cursor, user,host):
Mark Theunissen committed
160 161 162 163 164 165 166 167 168 169 170 171
    """ 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()
172 173 174 175 176 177 178

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

Mark Theunissen committed
179
    for grant in grants:
180
        res = re.match("GRANT (.+) ON (.+) TO '.+'@'.+'( IDENTIFIED BY PASSWORD '.+')? ?(.*)", grant[0])
Mark Theunissen committed
181
        if res is None:
182
            module.fail_json(msg="unable to parse the MySQL grant string")
Mark Theunissen committed
183
        privileges = res.group(1).split(", ")
184
        privileges = [ pick(x) for x in privileges]
185
        if "WITH GRANT OPTION" in res.group(4):
Michael DeHaan committed
186
            privileges.append('GRANT')
Mark Theunissen committed
187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211
        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

212
def privileges_revoke(cursor, user,host,db_table):
Mark Theunissen committed
213 214
    query = "REVOKE ALL PRIVILEGES ON %s FROM '%s'@'%s'" % (db_table,user,host)
    cursor.execute(query)
215 216
    query = "REVOKE GRANT OPTION ON %s FROM '%s'@'%s'" % (db_table,user,host)
    cursor.execute(query)
Mark Theunissen committed
217

218
def privileges_grant(cursor, user,host,db_table,priv):
219 220

    priv_string = ",".join(filter(lambda x: x != 'GRANT', priv))
Mark Theunissen committed
221
    query = "GRANT %s ON %s TO '%s'@'%s'" % (priv_string,db_table,user,host)
222
    if 'GRANT' in priv:
Michael DeHaan committed
223
        query = query + " WITH GRANT OPTION"
Mark Theunissen committed
224 225
    cursor.execute(query)

226 227 228
def load_mycnf():
    config = ConfigParser.RawConfigParser()
    mycnf = os.path.expanduser('~/.my.cnf')
229 230
    if not os.path.exists(mycnf):
        return False
231
    try:
232
        config.readfp(open(mycnf))
233
        creds = dict(user=config.get('client', 'user'),password=config.get('client', 'pass'))
234
    except (ConfigParser.NoOptionError, IOError):
235 236 237
        return False
    return creds

Mark Theunissen committed
238 239 240 241
# ===========================================
# Module execution.
#

242 243 244
def main():
    module = AnsibleModule(
        argument_spec = dict(
245 246 247
            login_user=dict(default=None),
            login_password=dict(default=None),
            login_host=dict(default="localhost"),
248
            login_unix_socket=dict(default=None),
249
            user=dict(required=True, aliases=['name']),
250
            password=dict(default=None),
251 252 253 254 255 256
            host=dict(default="localhost"),
            state=dict(default="present", choices=["absent", "present"]),
            priv=dict(default=None),
        )
    )
    user = module.params["user"]
257
    password = module.params["password"]
258 259 260 261 262 263 264 265 266 267 268 269
    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")
Mark Theunissen committed
270

271 272 273
    # 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.
274 275 276
    login_password = module.params["login_password"]
    login_user = module.params["login_user"]
    if login_user is None and login_password is None:
277 278
        mycnf_creds = load_mycnf()
        if mycnf_creds is False:
279 280
            login_user = "root"
            login_password = ""
281
        else:
282 283 284 285
            login_user = mycnf_creds["user"]
            login_password = mycnf_creds["password"]
    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")
286

Mark Theunissen committed
287
    try:
288
        if module.params["login_unix_socket"]:
289 290 291
            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")
Mark Theunissen committed
292
        cursor = db_connection.cursor()
293
    except Exception, e:
294
        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
295 296

    if state == "present":
297
        if user_exists(cursor, user, host):
298
            changed = user_mod(cursor, user, host, password, priv)
Mark Theunissen committed
299
        else:
300 301 302
            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
303
    elif state == "absent":
304 305
        if user_exists(cursor, user, host):
            changed = user_delete(cursor, user, host)
Mark Theunissen committed
306 307
        else:
            changed = False
308
    module.exit_json(changed=changed, user=user)
Mark Theunissen committed
309

310 311 312
# this is magic, see lib/ansible/module_common.py
#<<INCLUDE_ANSIBLE_MODULE_COMMON>>
main()