Skip to content

Commit 2b751d1

Browse files
committed
Support setting per-role config with ALTER ROLE SET
1 parent 58e46f9 commit 2b751d1

10 files changed

Lines changed: 206 additions & 54 deletions

File tree

README.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ As an example, the definition for the ``jdoe`` role in the spec might look like
4040
is_superuser: no
4141
attributes:
4242
- PASSWORD "{{ env['JDOE_PASSWORD'] }}"
43+
configs:
44+
statement_timeout: 42s
4345
member_of:
4446
- analyst
4547
owns:
@@ -75,6 +77,7 @@ When pgbedrock is run, it would make sure that:
7577
* ``jdoe`` is not a superuser
7678
* ``jdoe``'s password is the same as what is in the ``$JDOE_PASSWORD`` environment variable
7779
* All other role attributes for ``jdoe`` are the Postgres defaults (as defined by `pg_authid`_).
80+
* ``jdoe``’s session config ``statement_timeout`` is set to ``42s``
7881
* ``jdoe`` is a member of the ``analyst`` role
7982
* ``jdoe`` is a member of no other roles
8083
* ``jdoe`` owns the ``finance_reports`` schema

docs/index.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ As an example, the definition for the ``jdoe`` role in the spec might look like
2525
is_superuser: no
2626
attributes:
2727
- PASSWORD "{{ env['JDOE_PASSWORD'] }}"
28+
configs:
29+
statement_timeout: 42s
2830
member_of:
2931
- analyst
3032
owns:
@@ -58,6 +60,7 @@ When pgbedrock is run, it would make sure that:
5860
* ``jdoe`` is not a superuser
5961
* ``jdoe``'s password is the same as what is in the ``$JDOE_PASSWORD`` environment variable
6062
* All other role attributes for ``jdoe`` are the Postgres defaults (as defined by `pg_authid`_).
63+
* ``jdoe``’s session config ``statement_timeout`` is set to ``42s``
6164
* ``jdoe`` is a member of the ``analyst`` role
6265
* ``jdoe`` is a member of no other roles
6366
* ``jdoe`` owns the ``finance_reports`` schema

pgbedrock/attributes.py

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import datetime as dt
33
import hashlib
44
import logging
5+
import re
6+
import math
57

68
import click
79
import psycopg2
@@ -18,7 +20,9 @@
1820
Q_ALTER_CONN_LIMIT = 'ALTER ROLE "{}" WITH CONNECTION LIMIT {}; -- Previous value: {}'
1921
Q_ALTER_PASSWORD = "ALTER ROLE \"{}\" WITH ENCRYPTED PASSWORD '{}';"
2022
Q_REMOVE_PASSWORD = "ALTER ROLE \"{}\" WITH PASSWORD NULL;"
21-
Q_ALTER_ROLE = 'ALTER ROLE "{}" WITH {};'
23+
Q_ALTER_ROLE_WITH = 'ALTER ROLE "{}" WITH {};'
24+
Q_ALTER_ROLE_SET = 'ALTER ROLE "{}" SET {}={}; -- Previous value: {}'
25+
Q_ALTER_ROLE_RESET = 'ALTER ROLE "{}" RESET {}; -- Previous value: {}'
2226
Q_ALTER_VALID_UNTIL = "ALTER ROLE \"{}\" WITH VALID UNTIL '{}'; -- Previous value: {}"
2327
Q_CREATE_ROLE = 'CREATE ROLE "{}";'
2428

@@ -65,17 +69,19 @@ def analyze_attributes(spec, cursor, verbose):
6569
show_eta=False, item_show_func=common.item_show_func) as all_roles:
6670
all_sql_to_run = []
6771
password_all_sql_to_run = []
68-
for rolename, spec_config in all_roles:
72+
for rolename, spec_settings in all_roles:
6973
logger.debug('Starting to analyze role {}'.format(rolename))
7074

71-
spec_config = spec_config or {}
72-
spec_attributes = spec_config.get('attributes', [])
75+
spec_settings = spec_settings or {}
76+
spec_attributes = spec_settings.get('attributes', [])
7377

7478
for keyword, attribute in (('can_login', 'LOGIN'), ('is_superuser', 'SUPERUSER')):
75-
is_desired = spec_config.get(keyword, False)
79+
is_desired = spec_settings.get(keyword, False)
7680
spec_attributes.append(attribute if is_desired else 'NO' + attribute)
7781

78-
roleconf = AttributeAnalyzer(rolename, spec_attributes, dbcontext)
82+
spec_configs = spec_settings.get('config', {})
83+
84+
roleconf = AttributeAnalyzer(rolename, spec_attributes, spec_configs, dbcontext)
7985
roleconf.analyze()
8086
all_sql_to_run += roleconf.sql_to_run
8187
password_all_sql_to_run += roleconf.password_sql_to_run
@@ -102,13 +108,15 @@ class AttributeAnalyzer(object):
102108
make it match the provided spec attributes. Note that spec_attributes is a list whereas
103109
current_attributes is a dict. """
104110

105-
def __init__(self, rolename, spec_attributes, dbcontext):
111+
def __init__(self, rolename, spec_attributes, spec_configs, dbcontext):
106112
self.sql_to_run = []
107113
self.rolename = common.check_name(rolename)
108114
logger.debug('self.rolename set to {}'.format(self.rolename))
109115
self.spec_attributes = spec_attributes
116+
self.spec_configs = spec_configs
110117

111118
self.current_attributes = dbcontext.get_role_attributes(rolename)
119+
self.current_configs = dbcontext.get_role_configs(rolename)
112120

113121
# We keep track of password-related SQL separately as we don't want running this to
114122
# go into the main SQL stream since that could leak password
@@ -119,7 +127,10 @@ def analyze(self):
119127
self.create_role()
120128

121129
desired_attributes = self.coalesce_attributes()
130+
122131
self.set_all_attributes(desired_attributes)
132+
self.set_all_configs(self.spec_configs)
133+
123134
return self.sql_to_run
124135

125136
def create_role(self):
@@ -193,6 +204,13 @@ def get_attribute_value(self, attribute):
193204
logger.debug('Returning attribute "{}": "{}"'.format(attribute, value))
194205
return value
195206

207+
def get_config_value(self, config):
208+
""" Take a config (e.g. `statement_timeout`) and look up that value in our dbcontext
209+
"""
210+
value = self.current_configs.get(config, "")
211+
logger.debug('Returning config "{}": "{}"'.format(config, value))
212+
return value
213+
196214
def is_same_password(self, value):
197215
""" Convert the input value into a postgres rolname-salted md5 hash and compare
198216
it with the currently stored hash """
@@ -221,7 +239,7 @@ def set_all_attributes(self, attributes):
221239

222240
elif current_value != desired_value and attribute != 'rolpassword':
223241
self.set_attribute_value(attribute, desired_value, current_value)
224-
242+
225243
def set_attribute_value(self, attribute, desired_value, current_value):
226244
if attribute == 'rolconnlimit':
227245
query = Q_ALTER_CONN_LIMIT.format(self.rolename, desired_value, current_value)
@@ -231,10 +249,22 @@ def set_attribute_value(self, attribute, desired_value, current_value):
231249
base_keyword = COLUMN_NAME_TO_KEYWORD[attribute]
232250
# prepend 'NO' if desired_value is False
233251
keyword = base_keyword if desired_value else 'NO' + base_keyword
234-
query = Q_ALTER_ROLE.format(self.rolename, keyword)
252+
query = Q_ALTER_ROLE_WITH.format(self.rolename, keyword)
235253

236254
self.sql_to_run.append(query)
237255

256+
def set_all_configs(self, configs):
257+
for config, desired_value in configs.items():
258+
current_value = self.get_config_value(config)
259+
260+
if self.parse_config_value(current_value) != self.parse_config_value(desired_value):
261+
self.sql_to_run.append(Q_ALTER_ROLE_SET.format(self.rolename, config, desired_value, current_value))
262+
263+
if self.current_configs:
264+
for current_config, current_value in self.current_configs.items():
265+
if current_config not in configs.keys():
266+
self.sql_to_run.append(Q_ALTER_ROLE_RESET.format(self.rolename, current_config, current_value))
267+
238268
def set_password(self, desired_value):
239269
if desired_value is None:
240270
actual_query = Q_REMOVE_PASSWORD.format(self.rolename)
@@ -244,3 +274,70 @@ def set_password(self, desired_value):
244274

245275
sanitized_query = Q_ALTER_PASSWORD.format(self.rolename, '******')
246276
self.sql_to_run.append('--' + sanitized_query)
277+
278+
@staticmethod
279+
def parse_config_value(value):
280+
"""
281+
Parse a config (as in postgresql.conf setting) value and return it’s normalized value.
282+
283+
If the value matches a well-known unit it is rounded to a multiple of the
284+
next smaller unit (if there is one) then it is converted to kB for memory
285+
units and to ms for time units
286+
287+
If the value doesn’t match a well known unit it is returned as an int or a
288+
float if conversion is possible or as-is otherwise.
289+
290+
See: https://www.postgresql.org/docs/current/config-setting.html
291+
"""
292+
293+
if not isinstance(value, str):
294+
return value
295+
296+
try:
297+
return int(value)
298+
except ValueError:
299+
pass
300+
301+
try:
302+
return float(value)
303+
except ValueError:
304+
pass
305+
306+
# Valid memory units are B (bytes), kB (kilobytes), MB (megabytes), GB (gigabytes), and TB (terabytes).
307+
# The multiplier for memory units is 1024, not 1000.
308+
m = re.search("(?P<quantity>[\-\+]?[0-9]+(\.[0-9]+)?)\s*(?P<unit>B|kB|MB|GB|TB)", value)
309+
if m:
310+
quantity = float(m.group("quantity"))
311+
unit = m.group("unit")
312+
313+
if unit == "B":
314+
return quantity / 1024
315+
if unit == "kB":
316+
return math.floor(quantity * 1024) / 1024
317+
if unit == "MB":
318+
return math.floor(quantity * 1024)
319+
if unit == "GB":
320+
return math.floor(quantity * 1024) * 1024
321+
if unit == "TB":
322+
return math.floor(quantity * 1024) * 1024 ** 2
323+
324+
# Valid time units are us (microseconds), ms (milliseconds), s (seconds), min (minutes), h (hours), and d (days).
325+
m = re.search("(?P<quantity>.+)\s*(?P<unit>us|ms|s|min|h|d)", value)
326+
if m:
327+
quantity = float(m.group("quantity"))
328+
unit = m.group("unit")
329+
330+
if unit == "us":
331+
return quantity / 1000
332+
if unit == "ms":
333+
return math.floor(quantity * 1000) / 1000
334+
if unit == "s":
335+
return math.floor(quantity * 1000)
336+
if unit == "min":
337+
return math.floor(quantity * 60) * 1000
338+
if unit == "h":
339+
return math.floor(quantity * 60) * 60 * 1000
340+
if unit == "d":
341+
return math.floort(quantity * 24) * 60 * 60 * 1000
342+
343+
return value

pgbedrock/context.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@
124124
;
125125
"""
126126

127+
Q_GET_ALL_ROLE_CONFIGS = "SELECT usename, useconfig FROM pg_shadow;"
128+
127129
Q_GET_ALL_MEMBERSHIPS = """
128130
SELECT
129131
auth_member.rolname AS member,
@@ -427,6 +429,23 @@ def is_superuser(self, rolename):
427429
role_attributes = self.get_role_attributes(rolename)
428430
return role_attributes.get('rolsuper', False)
429431

432+
def get_all_role_configs(self):
433+
""" Return a dict of {rolname: {config_name: value}} from pg_shadow"""
434+
common.run_query(self.cursor, self.verbose, Q_GET_ALL_ROLE_CONFIGS)
435+
role_configs = {}
436+
for row in self.cursor.fetchall():
437+
role_configs[row['usename']] = {}
438+
439+
for config in row['useconfig'] or []:
440+
k, v = config.split('=')
441+
role_configs[row['usename']][k] = v
442+
443+
return role_configs
444+
445+
def get_role_configs(self, rolename):
446+
all_role_configs = self.get_all_role_configs()
447+
return all_role_configs.get(rolename, dict())
448+
430449
def get_all_raw_object_attributes(self):
431450
"""
432451
Fetch results for all object attributes.

pgbedrock/memberships.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ def analyze_memberships(spec, cursor, verbose):
2424
with click.progressbar(spec.items(), label='Analyzing memberships:', bar_template=bar_template,
2525
show_eta=False, item_show_func=common.item_show_func) as all_roles:
2626
all_sql_to_run = []
27-
for rolename, spec_config in all_roles:
28-
spec_config = spec_config or {}
29-
spec_memberships = set(spec_config.get('member_of', []))
27+
for rolename, spec_settings in all_roles:
28+
spec_settings = spec_settings or {}
29+
spec_memberships = set(spec_settings.get('member_of', []))
3030
sql_to_run = MembershipAnalyzer(rolename, spec_memberships, dbcontext).analyze()
3131
all_sql_to_run += sql_to_run
3232

0 commit comments

Comments
 (0)