Skip to content

Commit 7e1cc0e

Browse files
committed
Mysql op improvements (#627)
* Add `GRANT OPTION` to privileges in `mysql.MysqlUserGrants` fact. * Fix and test partial revoking in `mysql.privileges` operation. * Add `with_grant_option` argument to `mysql.privileges` operation. * Only grant/revoke the necessary privileges in `mysql.privileges` operation. * Implement `mysql.user` op support for SSL and resource limits. The operation now provides complete support for all the possible `REQUIRE` SSL/TLS options and `WITH` resource limit options. Existing users will be updates to reflect these values using `ALTER USER`. Also adds idempotency when new users are created, and expands the tests to cover all cases.
1 parent 45acd89 commit 7e1cc0e

24 files changed

+461
-35
lines changed

pyinfra/facts/mysql.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ def process(output):
163163
)
164164

165165

166+
# TODO: remove the dictionary and just have GRANT OPTION as a listed privilege
166167
class MysqlUserGrants(MysqlFactBase):
167168
'''
168169
Returns a dict of ``<database>`.<table>`` with granted privileges for each:
@@ -221,5 +222,6 @@ def process(output):
221222

222223
if 'WITH GRANT OPTION' in extras:
223224
database_table_privileges[database_table]['with_grant_option'] = True
225+
database_table_privileges[database_table]['privileges'].add('GRANT OPTION')
224226

225227
return database_table_privileges

pyinfra/operations/mysql.py

Lines changed: 187 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,25 @@ def sql(
4949
@operation
5050
def user(
5151
user,
52-
# Desired user settings
5352
present=True,
54-
user_hostname='localhost', password=None, privileges=None,
53+
user_hostname='localhost',
54+
password=None,
55+
privileges=None,
56+
# MySQL REQUIRE SSL/TLS options
57+
require=None, # SSL or X509
58+
require_cipher=False,
59+
require_issuer=False,
60+
require_subject=False,
61+
# MySQL WITH resource limit options
62+
max_connections=None,
63+
max_queries_per_hour=None,
64+
max_updates_per_hour=None,
65+
max_connections_per_hour=None,
5566
# Details for speaking to MySQL via `mysql` CLI via `mysql` CLI
56-
mysql_user=None, mysql_password=None,
57-
mysql_host=None, mysql_port=None,
67+
mysql_user=None,
68+
mysql_password=None,
69+
mysql_host=None,
70+
mysql_port=None,
5871
state=None, host=None,
5972
):
6073
'''
@@ -85,16 +98,52 @@ def user(
8598
user='pyinfra',
8699
password='somepassword',
87100
)
101+
102+
# Create a user with resource limits
103+
mysql.user(
104+
name='Create the pyinfra@localhost MySQL user',
105+
user='pyinfra',
106+
max_connections=50,
107+
max_updates_per_hour=10,
108+
)
109+
110+
# Create a user that requires SSL for connections
111+
mysql.user(
112+
name='Create the pyinfra@localhost MySQL user',
113+
user='pyinfra',
114+
password='somepassword',
115+
require='SSL',
116+
)
117+
118+
# Create a user that requires a specific certificate
119+
mysql.user(
120+
name='Create the pyinfra@localhost MySQL user',
121+
user='pyinfra',
122+
password='somepassword',
123+
require='X509',
124+
require_issuer='/C=SE/ST=Stockholm...',
125+
require_cipher='EDH-RSA-DES-CBC3-SHA',
126+
)
88127
'''
89128

129+
if require and require not in ('SSL', 'X509'):
130+
raise OperationError('Invalid `require` value, must be: "SSL" or "X509"')
131+
132+
if require != 'X509':
133+
if require_cipher:
134+
raise OperationError('Cannot set `require_cipher` if `require` is not "X509"')
135+
if require_issuer:
136+
raise OperationError('Cannot set `require_issuer` if `require` is not "X509"')
137+
if require_subject:
138+
raise OperationError('Cannot set `require_subject` if `require` is not "X509"')
139+
90140
current_users = host.fact.mysql_users(
91141
mysql_user, mysql_password, mysql_host, mysql_port,
92142
)
93143

94144
user_host = '{0}@{1}'.format(user, user_hostname)
95145
is_present = user_host in current_users
96146

97-
# User not wanted?
98147
if not present:
99148
if is_present:
100149
yield make_execute_mysql_command(
@@ -104,25 +153,124 @@ def user(
104153
host=mysql_host,
105154
port=mysql_port,
106155
)
156+
current_users.pop(user_host)
107157
else:
108158
host.noop('mysql user {0}@{1} does not exist'.format(user, user_hostname))
109159
return
110160

111-
# If we want the user and they don't exist
161+
new_or_updated_user_fact = {
162+
'ssl_type': 'ANY' if require == 'SSL' else require,
163+
'ssl_cipher': require_cipher,
164+
'x509_issuer': require_issuer,
165+
'x509_subject': require_subject,
166+
'max_user_connections': max_connections,
167+
'max_questions': max_queries_per_hour,
168+
'max_updates': max_updates_per_hour,
169+
'max_connections': max_connections_per_hour,
170+
}
171+
112172
if present and not is_present:
113173
sql_bits = ['CREATE USER "{0}"@"{1}"'.format(user, user_hostname)]
114174
if password:
115175
sql_bits.append(MaskString('IDENTIFIED BY "{0}"'.format(password)))
116176

177+
if require == 'SSL':
178+
sql_bits.append('REQUIRE SSL')
179+
180+
if require == 'X509':
181+
sql_bits.append('REQUIRE')
182+
require_bits = []
183+
184+
if require_cipher:
185+
require_bits.append('CIPHER "{0}"'.format(require_cipher))
186+
if require_issuer:
187+
require_bits.append('ISSUER "{0}"'.format(require_issuer))
188+
if require_subject:
189+
require_bits.append('SUBJECT "{0}"'.format(require_subject))
190+
191+
if not require_bits:
192+
require_bits.append('X509')
193+
194+
sql_bits.extend(require_bits)
195+
196+
resource_bits = []
197+
if max_connections:
198+
resource_bits.append('MAX_USER_CONNECTIONS {0}'.format(max_connections))
199+
if max_queries_per_hour:
200+
resource_bits.append('MAX_QUERIES_PER_HOUR {0}'.format(max_queries_per_hour))
201+
if max_updates_per_hour:
202+
resource_bits.append('MAX_UPDATES_PER_HOUR {0}'.format(max_updates_per_hour))
203+
if max_connections_per_hour:
204+
resource_bits.append('MAX_CONNECTIONS_PER_HOUR {0}'.format(max_connections_per_hour))
205+
206+
if resource_bits:
207+
sql_bits.append('WITH')
208+
sql_bits.append(' '.join(resource_bits))
209+
117210
yield make_execute_mysql_command(
118211
StringCommand(*sql_bits),
119212
user=mysql_user,
120213
password=mysql_password,
121214
host=mysql_host,
122215
port=mysql_port,
123216
)
124-
else:
125-
host.noop('mysql user {0}@{1} exists'.format(user, user_hostname))
217+
218+
current_users[user_host] = new_or_updated_user_fact
219+
220+
if present and is_present:
221+
current_user = current_users.get(user_host)
222+
223+
alter_bits = []
224+
225+
if require == 'SSL':
226+
if current_user['ssl_type'] != 'ANY':
227+
alter_bits.append('REQUIRE SSL')
228+
229+
if require == 'X509':
230+
require_bits = []
231+
232+
if require_cipher and current_user['ssl_cipher'] != require_cipher:
233+
require_bits.append('CIPHER "{0}"'.format(require_cipher))
234+
if require_issuer and current_user['x509_issuer'] != require_issuer:
235+
require_bits.append('ISSUER "{0}"'.format(require_issuer))
236+
if require_subject and current_user['x509_subject'] != require_subject:
237+
require_bits.append('SUBJECT "{0}"'.format(require_subject))
238+
239+
if not require_bits:
240+
if current_user['ssl_type'] != 'X509':
241+
require_bits.append('X509')
242+
243+
if require_bits:
244+
alter_bits.append('REQUIRE')
245+
alter_bits.extend(require_bits)
246+
247+
resource_bits = []
248+
if max_connections and current_user['max_user_connections'] != max_connections:
249+
resource_bits.append('MAX_USER_CONNECTIONS {0}'.format(max_connections))
250+
if max_queries_per_hour and current_user['max_questions'] != max_queries_per_hour:
251+
resource_bits.append('MAX_QUERIES_PER_HOUR {0}'.format(max_queries_per_hour))
252+
if max_updates_per_hour and current_user['max_updates'] != max_updates_per_hour:
253+
resource_bits.append('MAX_UPDATES_PER_HOUR {0}'.format(max_updates_per_hour))
254+
if max_connections_per_hour and current_user['max_connections'] != max_connections_per_hour:
255+
resource_bits.append('MAX_CONNECTIONS_PER_HOUR {0}'.format(max_connections_per_hour))
256+
257+
if resource_bits:
258+
alter_bits.append('WITH')
259+
alter_bits.append(' '.join(resource_bits))
260+
261+
if alter_bits:
262+
sql_bits = ['ALTER USER "{0}"@"{1}"'.format(user, user_hostname)]
263+
sql_bits.extend(alter_bits)
264+
yield make_execute_mysql_command(
265+
StringCommand(*sql_bits),
266+
user=mysql_user,
267+
password=mysql_password,
268+
host=mysql_host,
269+
port=mysql_port,
270+
)
271+
current_user.update(new_or_updated_user_fact)
272+
else:
273+
host.noop('mysql user {0}@{1} exists'.format(user, user_hostname))
126274

127275
# If we're here either the user exists or we just created them; either way
128276
# now we can check any privileges are set.
@@ -233,13 +381,17 @@ def database(
233381
)
234382

235383

384+
# TODO: make this behave like a proper state op in v2, by setting present=None as the default
385+
# and having that mode add/remove privileges to match the provided list. Retain True/False support
386+
# to ensure certain matches exist or not.
236387
@operation
237388
def privileges(
238389
user, privileges,
239390
user_hostname='localhost',
240391
database='*', table='*',
241392
present=True,
242393
flush=True,
394+
with_grant_option=None,
243395
# Details for speaking to MySQL via `mysql` CLI
244396
mysql_user=None, mysql_password=None,
245397
mysql_host=None, mysql_port=None,
@@ -253,15 +405,27 @@ def privileges(
253405
+ user_hostname: the hostname of the user
254406
+ database: name of the database to grant privileges to (defaults to all)
255407
+ table: name of the table to grant privileges to (defaults to all)
256-
+ present: whether these privileges should exist (False to ``REVOKE)
408+
+ present: whether these privileges should exist (False to ``REVOKE``)
257409
+ flush: whether to flush (and update) the privileges table after any changes
410+
+ with_grant_option: whether to add the with grant option privilege
258411
+ mysql_*: global module arguments, see above
412+
413+
Note:
414+
This operation will either ensure permissions exist or are removed for a given database
415+
& table combination. This means when ``present=True`` it won't add/remove any permissions
416+
that already exist but aren't passed in as ``privileges``.
259417
'''
260418

261419
# Ensure we have a list
262420
if isinstance(privileges, six.string_types):
263421
privileges = [privileges]
264422

423+
if (
424+
(present and with_grant_option)
425+
or (present is False and with_grant_option is False)
426+
):
427+
privileges.append('GRANT OPTION')
428+
265429
if database != '*':
266430
database = '`{0}`'.format(database)
267431

@@ -281,27 +445,20 @@ def privileges(
281445
mysql_host, mysql_port,
282446
)
283447

284-
has_privileges = False
285-
448+
existing_privileges = []
286449
if database_table in user_grants:
287450
existing_privileges = [
288451
'ALL' if privilege == 'ALL PRIVILEGES' else privilege
289452
for privilege in user_grants[database_table]['privileges']
290453
]
291454

292-
has_privileges = (
293-
database_table in user_grants
294-
and all(
295-
privilege in existing_privileges
296-
for privilege in privileges
297-
)
298-
)
299-
300455
target = action = None
301456

302457
# No privilege and we want it
303458
if present:
304-
if not has_privileges:
459+
missing_privileges = [p for p in privileges if p not in existing_privileges]
460+
if missing_privileges:
461+
privileges_to_apply = missing_privileges
305462
action = 'GRANT'
306463
target = 'TO'
307464
else:
@@ -310,7 +467,9 @@ def privileges(
310467

311468
# Permission we don't want
312469
if not present:
313-
if has_privileges:
470+
unwanted_privileges = [p for p in privileges if p in existing_privileges]
471+
if unwanted_privileges:
472+
privileges_to_apply = unwanted_privileges
314473
action = 'REVOKE'
315474
target = 'FROM'
316475
else:
@@ -323,10 +482,13 @@ def privileges(
323482
'ON {database}.{table} '
324483
'{target} "{user}"@"{user_hostname}"'
325484
).format(
326-
privileges=', '.join(privileges),
327-
action=action, target=target,
328-
database=database, table=table,
329-
user=user, user_hostname=user_hostname,
485+
privileges=', '.join(privileges_to_apply),
486+
action=action,
487+
target=target,
488+
database=database,
489+
table=table,
490+
user=user,
491+
user_hostname=user_hostname,
330492
)
331493

332494
yield make_execute_mysql_command(

tests/facts/mysql.MysqlUserGrants/multiple.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
},
1616
"`some_database`.*": {
1717
"privileges": [
18-
"ALL PRIVILEGES"
18+
"ALL PRIVILEGES",
19+
"GRANT OPTION"
1920
],
2021
"with_grant_option": true
2122
}

tests/operations/mysql.privileges/add.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
{
2-
"args": ["someuser", ["SELECT", "INSERT"]],
2+
"args": ["someuser", ["SELECT", "INSERT", "DELETE"]],
33
"facts": {
44
"mysql_user_grants": {
55
"someuser": {
66
"localhost": {
77
"*.*": {
8-
"privileges": []
8+
"privileges": ["DELETE"]
99
}
1010
}
1111
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"args": ["someuser", []],
3+
"kwargs": {
4+
"with_grant_option": true
5+
},
6+
"facts": {
7+
"mysql_user_grants": {
8+
"someuser": {
9+
"localhost": {
10+
"*.*": {
11+
"privileges": [],
12+
"with_grant_option": false
13+
}
14+
}
15+
}
16+
}
17+
},
18+
"commands": [
19+
"mysql -Be 'GRANT GRANT OPTION ON *.* TO \"someuser\"@\"localhost\"'",
20+
"mysql -Be 'FLUSH PRIVILEGES'"
21+
],
22+
"idempotent": false
23+
}

tests/operations/mysql.privileges/delete.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"args": ["someuser", ["SELECT", "INSERT"]],
2+
"args": ["someuser", ["SELECT", "INSERT", "DELETE"]],
33
"kwargs": {
44
"present": false
55
},

0 commit comments

Comments
 (0)