postgresql-charms-single-kernel 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- postgresql_charms_single_kernel-0.0.1.dist-info/METADATA +13 -0
- postgresql_charms_single_kernel-0.0.1.dist-info/RECORD +10 -0
- postgresql_charms_single_kernel-0.0.1.dist-info/WHEEL +5 -0
- postgresql_charms_single_kernel-0.0.1.dist-info/top_level.txt +1 -0
- single_kernel_postgresql/__init__.py +3 -0
- single_kernel_postgresql/abstract_charm.py +26 -0
- single_kernel_postgresql/config/__init__.py +3 -0
- single_kernel_postgresql/config/literals.py +17 -0
- single_kernel_postgresql/utils/__init__.py +3 -0
- single_kernel_postgresql/utils/postgresql.py +1793 -0
|
@@ -0,0 +1,1793 @@
|
|
|
1
|
+
# Copyright 2025 Canonical Ltd.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""PostgreSQL helper class.
|
|
16
|
+
|
|
17
|
+
The `postgresql` module provides methods for interacting with the PostgreSQL instance.
|
|
18
|
+
|
|
19
|
+
Any charm using this library should import the `psycopg2` or `psycopg2-binary` dependency.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
from collections import OrderedDict
|
|
24
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
25
|
+
|
|
26
|
+
import psycopg2
|
|
27
|
+
from ops import ConfigData
|
|
28
|
+
from psycopg2.sql import SQL, Identifier, Literal
|
|
29
|
+
|
|
30
|
+
from ..config.literals import BACKUP_USER, SYSTEM_USERS
|
|
31
|
+
|
|
32
|
+
# Groups to distinguish HBA access
|
|
33
|
+
ACCESS_GROUP_IDENTITY = "identity_access"
|
|
34
|
+
ACCESS_GROUP_INTERNAL = "internal_access"
|
|
35
|
+
ACCESS_GROUP_RELATION = "relation_access"
|
|
36
|
+
|
|
37
|
+
# List of access groups to filter role assignments by
|
|
38
|
+
ACCESS_GROUPS = [
|
|
39
|
+
ACCESS_GROUP_IDENTITY,
|
|
40
|
+
ACCESS_GROUP_INTERNAL,
|
|
41
|
+
ACCESS_GROUP_RELATION,
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
ROLE_STATS = "charmed_stats"
|
|
45
|
+
ROLE_READ = "charmed_read"
|
|
46
|
+
ROLE_DML = "charmed_dml"
|
|
47
|
+
ROLE_BACKUP = "charmed_backup"
|
|
48
|
+
ROLE_DBA = "charmed_dba"
|
|
49
|
+
ROLE_ADMIN = "charmed_admin"
|
|
50
|
+
ROLE_DATABASES_OWNER = "charmed_databases_owner"
|
|
51
|
+
ALLOWED_ROLES = {
|
|
52
|
+
ROLE_STATS,
|
|
53
|
+
ROLE_READ,
|
|
54
|
+
ROLE_DML,
|
|
55
|
+
ROLE_ADMIN,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
INVALID_DATABASE_NAME_BLOCKING_MESSAGE = "invalid database name"
|
|
59
|
+
INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE = "invalid role(s) for extra user roles"
|
|
60
|
+
|
|
61
|
+
REQUIRED_PLUGINS = {
|
|
62
|
+
"address_standardizer": ["postgis"],
|
|
63
|
+
"address_standardizer_data_us": ["postgis"],
|
|
64
|
+
"jsonb_plperl": ["plperl"],
|
|
65
|
+
"postgis_raster": ["postgis"],
|
|
66
|
+
"postgis_tiger_geocoder": ["postgis", "fuzzystrmatch"],
|
|
67
|
+
"postgis_topology": ["postgis"],
|
|
68
|
+
}
|
|
69
|
+
DEPENDENCY_PLUGINS = set()
|
|
70
|
+
for dependencies in REQUIRED_PLUGINS.values():
|
|
71
|
+
DEPENDENCY_PLUGINS |= set(dependencies)
|
|
72
|
+
|
|
73
|
+
logger = logging.getLogger(__name__)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PostgreSQLBaseError(Exception):
|
|
77
|
+
"""Base lib exception."""
|
|
78
|
+
|
|
79
|
+
message = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PostgreSQLAssignGroupError(PostgreSQLBaseError):
|
|
83
|
+
"""Exception raised when assigning to a group fails."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PostgreSQLCreateDatabaseError(PostgreSQLBaseError):
|
|
87
|
+
"""Exception raised when creating a database fails."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, message: Optional[str] = None):
|
|
90
|
+
super().__init__(message)
|
|
91
|
+
self.message = message
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PostgreSQLCreateGroupError(PostgreSQLBaseError):
|
|
95
|
+
"""Exception raised when creating a group fails."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class PostgreSQLCreateUserError(PostgreSQLBaseError):
|
|
99
|
+
"""Exception raised when creating a user fails."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, message: Optional[str] = None):
|
|
102
|
+
super().__init__(message)
|
|
103
|
+
self.message = message
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class PostgreSQLUndefinedHostError(PostgreSQLBaseError):
|
|
107
|
+
"""Exception when host is not set."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class PostgreSQLUndefinedPasswordError(PostgreSQLBaseError):
|
|
111
|
+
"""Exception when password is not set."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class PostgreSQLDatabasesSetupError(PostgreSQLBaseError):
|
|
115
|
+
"""Exception raised when the databases setup fails."""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class PostgreSQLDeleteUserError(PostgreSQLBaseError):
|
|
119
|
+
"""Exception raised when deleting a user fails."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class PostgreSQLEnableDisableExtensionError(PostgreSQLBaseError):
|
|
123
|
+
"""Exception raised when enabling/disabling an extension fails."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class PostgreSQLGetLastArchivedWALError(PostgreSQLBaseError):
|
|
127
|
+
"""Exception raised when retrieving last archived WAL fails."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class PostgreSQLGetCurrentTimelineError(PostgreSQLBaseError):
|
|
131
|
+
"""Exception raised when retrieving current timeline id for the PostgreSQL unit fails."""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class PostgreSQLGetPostgreSQLVersionError(PostgreSQLBaseError):
|
|
135
|
+
"""Exception raised when retrieving PostgreSQL version fails."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class PostgreSQLListAccessibleDatabasesForUserError(PostgreSQLBaseError):
|
|
139
|
+
"""Exception raised when retrieving the accessible databases for a user fails."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class PostgreSQLListGroupsError(PostgreSQLBaseError):
|
|
143
|
+
"""Exception raised when retrieving PostgreSQL groups list fails."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class PostgreSQLListUsersError(PostgreSQLBaseError):
|
|
147
|
+
"""Exception raised when retrieving PostgreSQL users list fails."""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class PostgreSQLUpdateUserPasswordError(PostgreSQLBaseError):
|
|
151
|
+
"""Exception raised when updating a user password fails."""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class PostgreSQLCreatePredefinedRolesError(PostgreSQLBaseError):
|
|
155
|
+
"""Exception raised when creating predefined roles."""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class PostgreSQLDatabaseExistsError(PostgreSQLBaseError):
|
|
159
|
+
"""Exception raised during database existence check."""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class PostgreSQLTableExistsError(PostgreSQLBaseError):
|
|
163
|
+
"""Exception raised during table existence check."""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class PostgreSQLIsTableEmptyError(PostgreSQLBaseError):
|
|
167
|
+
"""Exception raised during table emptiness check."""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class PostgreSQLCreatePublicationError(PostgreSQLBaseError):
|
|
171
|
+
"""Exception raised when creating PostgreSQL publication."""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class PostgreSQLPublicationExistsError(PostgreSQLBaseError):
|
|
175
|
+
"""Exception raised during PostgreSQL publication existence check."""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class PostgreSQLAlterPublicationError(PostgreSQLBaseError):
|
|
179
|
+
"""Exception raised when altering PostgreSQL publication."""
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class PostgreSQLDropPublicationError(PostgreSQLBaseError):
|
|
183
|
+
"""Exception raised when dropping PostgreSQL publication."""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class PostgreSQLCreateSubscriptionError(PostgreSQLBaseError):
|
|
187
|
+
"""Exception raised when creating PostgreSQL subscription."""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class PostgreSQLSubscriptionExistsError(PostgreSQLBaseError):
|
|
191
|
+
"""Exception raised during PostgreSQL subscription existence check."""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class PostgreSQLUpdateSubscriptionError(PostgreSQLBaseError):
|
|
195
|
+
"""Exception raised when updating PostgreSQL subscription."""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class PostgreSQLRefreshSubscriptionError(PostgreSQLBaseError):
|
|
199
|
+
"""Exception raised when refreshing PostgreSQL subscription."""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class PostgreSQLDropSubscriptionError(PostgreSQLBaseError):
|
|
203
|
+
"""Exception raised when dropping PostgreSQL subscription."""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class PostgreSQLGrantDatabasePrivilegesToUserError(PostgreSQLBaseError):
|
|
207
|
+
"""Exception raised when granting database privileges to user."""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class PostgreSQL:
|
|
211
|
+
"""Class to encapsulate all operations related to interacting with PostgreSQL instance."""
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
primary_host: Optional[str],
|
|
216
|
+
current_host: Optional[str],
|
|
217
|
+
user: str,
|
|
218
|
+
password: Optional[str],
|
|
219
|
+
database: str,
|
|
220
|
+
system_users: Optional[List[str]] = None,
|
|
221
|
+
):
|
|
222
|
+
self.primary_host = primary_host
|
|
223
|
+
self.current_host = current_host
|
|
224
|
+
self.user = user
|
|
225
|
+
self.password = password
|
|
226
|
+
self.database = database
|
|
227
|
+
self.system_users = system_users if system_users else []
|
|
228
|
+
|
|
229
|
+
def _configure_pgaudit(self, enable: bool) -> None:
|
|
230
|
+
connection = None
|
|
231
|
+
try:
|
|
232
|
+
connection = self._connect_to_database()
|
|
233
|
+
connection.autocommit = True
|
|
234
|
+
with connection.cursor() as cursor:
|
|
235
|
+
cursor.execute("RESET ROLE;")
|
|
236
|
+
if enable:
|
|
237
|
+
cursor.execute("ALTER SYSTEM SET pgaudit.log = 'ROLE,DDL,MISC,MISC_SET';")
|
|
238
|
+
cursor.execute("ALTER SYSTEM SET pgaudit.log_client TO off;")
|
|
239
|
+
cursor.execute("ALTER SYSTEM SET pgaudit.log_parameter TO off;")
|
|
240
|
+
else:
|
|
241
|
+
cursor.execute("ALTER SYSTEM RESET pgaudit.log;")
|
|
242
|
+
cursor.execute("ALTER SYSTEM RESET pgaudit.log_client;")
|
|
243
|
+
cursor.execute("ALTER SYSTEM RESET pgaudit.log_parameter;")
|
|
244
|
+
cursor.execute("SELECT pg_reload_conf();")
|
|
245
|
+
finally:
|
|
246
|
+
if connection is not None:
|
|
247
|
+
connection.close()
|
|
248
|
+
|
|
249
|
+
def _connect_to_database(
|
|
250
|
+
self, database: Optional[str] = None, database_host: Optional[str] = None
|
|
251
|
+
) -> psycopg2.extensions.connection:
|
|
252
|
+
"""Creates a connection to the database.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
database: database to connect to (defaults to the database
|
|
256
|
+
provided when the object for this class was created).
|
|
257
|
+
database_host: host to connect to instead of the primary host.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
psycopg2 connection object.
|
|
261
|
+
"""
|
|
262
|
+
host = database_host if database_host is not None else self.primary_host
|
|
263
|
+
if not host:
|
|
264
|
+
raise PostgreSQLUndefinedHostError("Host not set")
|
|
265
|
+
if not self.password:
|
|
266
|
+
raise PostgreSQLUndefinedPasswordError("Password not set")
|
|
267
|
+
|
|
268
|
+
dbname = database if database else self.database
|
|
269
|
+
logger.debug(
|
|
270
|
+
f"New DB connection: dbname='{dbname}' user='{self.user}' host='{host}' connect_timeout=1"
|
|
271
|
+
)
|
|
272
|
+
connection = psycopg2.connect(
|
|
273
|
+
f"dbname='{dbname}' user='{self.user}' host='{host}'"
|
|
274
|
+
f"password='{self.password}' connect_timeout=1"
|
|
275
|
+
)
|
|
276
|
+
connection.autocommit = True
|
|
277
|
+
return connection
|
|
278
|
+
|
|
279
|
+
def create_access_groups(self) -> None:
|
|
280
|
+
"""Create access groups to distinguish HBA authentication methods."""
|
|
281
|
+
connection = None
|
|
282
|
+
try:
|
|
283
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
284
|
+
for group in ACCESS_GROUPS:
|
|
285
|
+
cursor.execute(
|
|
286
|
+
SQL("SELECT TRUE FROM pg_roles WHERE rolname={};").format(Literal(group))
|
|
287
|
+
)
|
|
288
|
+
if cursor.fetchone() is not None:
|
|
289
|
+
continue
|
|
290
|
+
cursor.execute(
|
|
291
|
+
SQL("CREATE ROLE {} NOLOGIN;").format(
|
|
292
|
+
Identifier(group),
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
except psycopg2.Error as e:
|
|
296
|
+
logger.error(f"Failed to create access groups: {e}")
|
|
297
|
+
raise PostgreSQLCreateGroupError() from e
|
|
298
|
+
finally:
|
|
299
|
+
if connection is not None:
|
|
300
|
+
connection.close()
|
|
301
|
+
|
|
302
|
+
def create_database(
|
|
303
|
+
self,
|
|
304
|
+
database: str,
|
|
305
|
+
plugins: Optional[List[str]] = None,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Creates a new database and grant privileges to a user on it.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
database: database to be created.
|
|
311
|
+
plugins: extensions to enable in the new database.
|
|
312
|
+
"""
|
|
313
|
+
# The limit of 49 characters for the database name is due to the usernames that
|
|
314
|
+
# are created for each database, which have the prefix `charmed_` and a suffix
|
|
315
|
+
# like `_owner`, which summed to the database name must not exceed PostgreSQL
|
|
316
|
+
# maximum identifier length (63 characters, which is, the prefix, 8 characters,
|
|
317
|
+
# + database name, 49 characters maximum, + suffix, 6 characters).
|
|
318
|
+
if len(database) > 49:
|
|
319
|
+
logger.error(f"Invalid database name (it must not exceed 49 characters): {database}.")
|
|
320
|
+
raise PostgreSQLCreateDatabaseError(INVALID_DATABASE_NAME_BLOCKING_MESSAGE)
|
|
321
|
+
if database in ["postgres", "template0", "template1"]:
|
|
322
|
+
logger.error(f"Invalid database name: {database}.")
|
|
323
|
+
raise PostgreSQLCreateDatabaseError(INVALID_DATABASE_NAME_BLOCKING_MESSAGE)
|
|
324
|
+
plugins = plugins if plugins else []
|
|
325
|
+
try:
|
|
326
|
+
connection = self._connect_to_database()
|
|
327
|
+
cursor = connection.cursor()
|
|
328
|
+
cursor.execute(
|
|
329
|
+
SQL("SELECT datname FROM pg_database WHERE datname={};").format(Literal(database))
|
|
330
|
+
)
|
|
331
|
+
if cursor.fetchone() is None:
|
|
332
|
+
cursor.execute(SQL("SET ROLE {};").format(Identifier(ROLE_DATABASES_OWNER)))
|
|
333
|
+
cursor.execute(SQL("CREATE DATABASE {};").format(Identifier(database)))
|
|
334
|
+
cursor.execute(
|
|
335
|
+
SQL("REVOKE ALL PRIVILEGES ON DATABASE {} FROM PUBLIC;").format(
|
|
336
|
+
Identifier(database)
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
with self._connect_to_database(database=database) as conn, conn.cursor() as curs:
|
|
340
|
+
curs.execute(SQL("SET ROLE {};").format(Identifier(ROLE_DATABASES_OWNER)))
|
|
341
|
+
curs.execute(SQL("SELECT set_up_predefined_catalog_roles();"))
|
|
342
|
+
except psycopg2.Error as e:
|
|
343
|
+
logger.error(f"Failed to create database: {e}")
|
|
344
|
+
raise PostgreSQLCreateDatabaseError() from e
|
|
345
|
+
|
|
346
|
+
# Enable preset extensions
|
|
347
|
+
if plugins:
|
|
348
|
+
self.enable_disable_extensions(dict.fromkeys(plugins, True), database)
|
|
349
|
+
|
|
350
|
+
def create_user(
|
|
351
|
+
self,
|
|
352
|
+
user: str,
|
|
353
|
+
password: Optional[str] = None,
|
|
354
|
+
admin: bool = False,
|
|
355
|
+
replication: bool = False,
|
|
356
|
+
extra_user_roles: Optional[List[str]] = None,
|
|
357
|
+
database: Optional[str] = None,
|
|
358
|
+
can_create_database: bool = False,
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Creates a database user.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
user: user to be created.
|
|
364
|
+
password: password to be assigned to the user.
|
|
365
|
+
admin: whether the user should have additional admin privileges.
|
|
366
|
+
replication: whether the user should have replication privileges.
|
|
367
|
+
extra_user_roles: additional privileges and/or roles to be assigned to the user.
|
|
368
|
+
database: optional database to allow the user to connect to.
|
|
369
|
+
can_create_database: whether the user should be able to create databases.
|
|
370
|
+
"""
|
|
371
|
+
try:
|
|
372
|
+
roles, privileges = self._process_extra_user_roles(user, extra_user_roles)
|
|
373
|
+
|
|
374
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
375
|
+
# Create or update the user.
|
|
376
|
+
cursor.execute(
|
|
377
|
+
SQL("SELECT TRUE FROM pg_roles WHERE rolname={};").format(Literal(user))
|
|
378
|
+
)
|
|
379
|
+
if cursor.fetchone() is not None:
|
|
380
|
+
user_definition = "ALTER ROLE {} "
|
|
381
|
+
else:
|
|
382
|
+
user_definition = "CREATE ROLE {} "
|
|
383
|
+
user_definition += f"WITH LOGIN{' SUPERUSER' if admin else ''}{' REPLICATION' if replication else ''} ENCRYPTED PASSWORD '{password}'"
|
|
384
|
+
user_definition, connect_statements = self._adjust_user_definition(
|
|
385
|
+
user, roles, database, user_definition
|
|
386
|
+
)
|
|
387
|
+
if can_create_database:
|
|
388
|
+
user_definition += " CREATEDB"
|
|
389
|
+
if privileges:
|
|
390
|
+
user_definition += f" {' '.join(privileges)}"
|
|
391
|
+
cursor.execute(SQL("RESET ROLE;"))
|
|
392
|
+
cursor.execute(SQL("BEGIN;"))
|
|
393
|
+
cursor.execute(SQL("SET LOCAL log_statement = 'none';"))
|
|
394
|
+
cursor.execute(SQL(f"{user_definition};").format(Identifier(user)))
|
|
395
|
+
cursor.execute(SQL("COMMIT;"))
|
|
396
|
+
if len(connect_statements) > 0:
|
|
397
|
+
for connect_statement in connect_statements:
|
|
398
|
+
cursor.execute(connect_statement)
|
|
399
|
+
|
|
400
|
+
# Add extra user roles to the new user.
|
|
401
|
+
if roles:
|
|
402
|
+
for role in roles:
|
|
403
|
+
cursor.execute(
|
|
404
|
+
SQL("GRANT {} TO {};").format(Identifier(role), Identifier(user))
|
|
405
|
+
)
|
|
406
|
+
except psycopg2.Error as e:
|
|
407
|
+
logger.error(f"Failed to create user: {e}")
|
|
408
|
+
raise PostgreSQLCreateUserError() from e
|
|
409
|
+
|
|
410
|
+
def _adjust_user_definition(
|
|
411
|
+
self, user: str, roles: Optional[List[str]], database: Optional[str], user_definition: str
|
|
412
|
+
) -> Tuple[str, List[str]]:
|
|
413
|
+
"""Adjusts the user definition to include additional statements.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
A tuple containing the adjusted user definition and a list of additional statements.
|
|
417
|
+
"""
|
|
418
|
+
connect_statements = []
|
|
419
|
+
if database:
|
|
420
|
+
if roles is not None and not any(
|
|
421
|
+
True
|
|
422
|
+
for role in roles
|
|
423
|
+
if role in [ROLE_STATS, ROLE_READ, ROLE_DML, ROLE_BACKUP, ROLE_DBA]
|
|
424
|
+
):
|
|
425
|
+
user_definition += f' IN ROLE "charmed_{database}_admin", "charmed_{database}_dml"'
|
|
426
|
+
else:
|
|
427
|
+
connect_statements.append(
|
|
428
|
+
SQL("GRANT CONNECT ON DATABASE {} TO {};").format(
|
|
429
|
+
Identifier(database), Identifier(user)
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
if roles is not None and any(
|
|
433
|
+
True
|
|
434
|
+
for role in roles
|
|
435
|
+
if role
|
|
436
|
+
in [
|
|
437
|
+
ROLE_STATS,
|
|
438
|
+
ROLE_READ,
|
|
439
|
+
ROLE_DML,
|
|
440
|
+
ROLE_BACKUP,
|
|
441
|
+
ROLE_DBA,
|
|
442
|
+
ROLE_ADMIN,
|
|
443
|
+
ROLE_DATABASES_OWNER,
|
|
444
|
+
]
|
|
445
|
+
):
|
|
446
|
+
for system_database in ["postgres", "template1"]:
|
|
447
|
+
connect_statements.append(
|
|
448
|
+
SQL("GRANT CONNECT ON DATABASE {} TO {};").format(
|
|
449
|
+
Identifier(system_database), Identifier(user)
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
return user_definition, connect_statements
|
|
453
|
+
|
|
454
|
+
def _process_extra_user_roles(
|
|
455
|
+
self, user: str, extra_user_roles: Optional[List[str]] = None
|
|
456
|
+
) -> Tuple[Optional[List[str]], Optional[Set[str]]]:
|
|
457
|
+
# Separate roles and privileges from the provided extra user roles.
|
|
458
|
+
roles = privileges = None
|
|
459
|
+
if extra_user_roles:
|
|
460
|
+
if len(extra_user_roles) > 2 and sorted(extra_user_roles) != [
|
|
461
|
+
ROLE_ADMIN,
|
|
462
|
+
"createdb",
|
|
463
|
+
ACCESS_GROUP_RELATION,
|
|
464
|
+
]:
|
|
465
|
+
extra_user_roles.remove(ACCESS_GROUP_RELATION)
|
|
466
|
+
logger.error(
|
|
467
|
+
"Invalid extra user roles: "
|
|
468
|
+
f"{', '.join(extra_user_roles)}. "
|
|
469
|
+
f"Only 'createdb' and '{ROLE_ADMIN}' are allowed together."
|
|
470
|
+
)
|
|
471
|
+
raise PostgreSQLCreateUserError(INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE)
|
|
472
|
+
valid_privileges, valid_roles = self.list_valid_privileges_and_roles()
|
|
473
|
+
roles = [
|
|
474
|
+
role
|
|
475
|
+
for role in extra_user_roles
|
|
476
|
+
if (
|
|
477
|
+
user == BACKUP_USER
|
|
478
|
+
or user in SYSTEM_USERS
|
|
479
|
+
or role in valid_roles
|
|
480
|
+
or role == ACCESS_GROUP_RELATION
|
|
481
|
+
or role == "createdb"
|
|
482
|
+
)
|
|
483
|
+
]
|
|
484
|
+
if "createdb" in extra_user_roles:
|
|
485
|
+
extra_user_roles.remove("createdb")
|
|
486
|
+
roles.remove("createdb")
|
|
487
|
+
extra_user_roles.append(ROLE_DATABASES_OWNER)
|
|
488
|
+
roles.append(ROLE_DATABASES_OWNER)
|
|
489
|
+
privileges = {
|
|
490
|
+
extra_user_role
|
|
491
|
+
for extra_user_role in extra_user_roles
|
|
492
|
+
if extra_user_role and extra_user_role not in roles
|
|
493
|
+
}
|
|
494
|
+
invalid_privileges = [
|
|
495
|
+
privilege for privilege in privileges if privilege not in valid_privileges
|
|
496
|
+
]
|
|
497
|
+
if len(invalid_privileges) > 0:
|
|
498
|
+
logger.error(f"Invalid extra user roles: {', '.join(privileges)}")
|
|
499
|
+
raise PostgreSQLCreateUserError(INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE)
|
|
500
|
+
return roles, privileges
|
|
501
|
+
|
|
502
|
+
def create_predefined_instance_roles(self) -> None:
|
|
503
|
+
"""Create predefined instance roles."""
|
|
504
|
+
connection = None
|
|
505
|
+
try:
|
|
506
|
+
for database in self._get_existing_databases():
|
|
507
|
+
with self._connect_to_database(
|
|
508
|
+
database=database,
|
|
509
|
+
) as connection, connection.cursor() as cursor:
|
|
510
|
+
cursor.execute(SQL("CREATE EXTENSION IF NOT EXISTS set_user;"))
|
|
511
|
+
finally:
|
|
512
|
+
if connection is not None:
|
|
513
|
+
connection.close()
|
|
514
|
+
connection = None
|
|
515
|
+
|
|
516
|
+
role_to_queries = {
|
|
517
|
+
ROLE_STATS: [
|
|
518
|
+
f"CREATE ROLE {ROLE_STATS} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE pg_monitor",
|
|
519
|
+
],
|
|
520
|
+
ROLE_READ: [
|
|
521
|
+
f"CREATE ROLE {ROLE_READ} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE pg_read_all_data, {ROLE_STATS}",
|
|
522
|
+
],
|
|
523
|
+
ROLE_DML: [
|
|
524
|
+
f"CREATE ROLE {ROLE_DML} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE pg_write_all_data, {ROLE_READ}",
|
|
525
|
+
],
|
|
526
|
+
ROLE_BACKUP: [
|
|
527
|
+
f"CREATE ROLE {ROLE_BACKUP} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE pg_checkpoint",
|
|
528
|
+
f"GRANT {ROLE_STATS} TO {ROLE_BACKUP}",
|
|
529
|
+
f"GRANT execute ON FUNCTION pg_backup_start TO {ROLE_BACKUP}",
|
|
530
|
+
f"GRANT execute ON FUNCTION pg_backup_stop TO {ROLE_BACKUP}",
|
|
531
|
+
f"GRANT execute ON FUNCTION pg_create_restore_point TO {ROLE_BACKUP}",
|
|
532
|
+
f"GRANT execute ON FUNCTION pg_switch_wal TO {ROLE_BACKUP}",
|
|
533
|
+
],
|
|
534
|
+
ROLE_DBA: [
|
|
535
|
+
f"CREATE ROLE {ROLE_DBA} NOSUPERUSER CREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE {ROLE_DML};"
|
|
536
|
+
],
|
|
537
|
+
ROLE_ADMIN: [
|
|
538
|
+
f"CREATE ROLE {ROLE_ADMIN} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE {ROLE_DML}",
|
|
539
|
+
],
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
try:
|
|
543
|
+
for database in ["postgres", "template1"]:
|
|
544
|
+
with self._connect_to_database(
|
|
545
|
+
database=database,
|
|
546
|
+
) as connection, connection.cursor() as cursor:
|
|
547
|
+
existing_roles = self.list_existing_roles()
|
|
548
|
+
for role, queries in role_to_queries.items():
|
|
549
|
+
for index, query in enumerate(queries):
|
|
550
|
+
if index == 0:
|
|
551
|
+
if role in existing_roles:
|
|
552
|
+
logger.debug(f"Role {role} already exists")
|
|
553
|
+
continue
|
|
554
|
+
else:
|
|
555
|
+
logger.info(f"Creating predefined role {role}")
|
|
556
|
+
cursor.execute(SQL(query))
|
|
557
|
+
except psycopg2.Error as e:
|
|
558
|
+
logger.error(f"Failed to create predefined instance roles: {e}")
|
|
559
|
+
raise PostgreSQLCreatePredefinedRolesError() from e
|
|
560
|
+
finally:
|
|
561
|
+
if connection is not None:
|
|
562
|
+
connection.close()
|
|
563
|
+
|
|
564
|
+
def grant_database_privileges_to_user(
|
|
565
|
+
self, user: str, database: str, privileges: List[str]
|
|
566
|
+
) -> None:
|
|
567
|
+
"""Grant the specified privileges on the provided database for the user."""
|
|
568
|
+
try:
|
|
569
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
570
|
+
cursor.execute(
|
|
571
|
+
SQL("GRANT {} ON DATABASE {} TO {};").format(
|
|
572
|
+
Identifier(", ".join(privileges)), Identifier(database), Identifier(user)
|
|
573
|
+
)
|
|
574
|
+
)
|
|
575
|
+
except psycopg2.Error as e:
|
|
576
|
+
logger.error(f"Failed to grant privileges to user: {e}")
|
|
577
|
+
raise PostgreSQLGrantDatabasePrivilegesToUserError() from e
|
|
578
|
+
|
|
579
|
+
def delete_user(self, user: str) -> None:
|
|
580
|
+
"""Deletes a database user.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
user: user to be deleted.
|
|
584
|
+
"""
|
|
585
|
+
# First of all, check whether the user exists. Otherwise, do nothing.
|
|
586
|
+
users = self.list_users()
|
|
587
|
+
if user not in users:
|
|
588
|
+
return
|
|
589
|
+
|
|
590
|
+
# List all databases.
|
|
591
|
+
try:
|
|
592
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
593
|
+
cursor.execute("SELECT datname FROM pg_database WHERE datistemplate = false;")
|
|
594
|
+
databases = [row[0] for row in cursor.fetchall()]
|
|
595
|
+
|
|
596
|
+
# Existing objects need to be reassigned in each database
|
|
597
|
+
# before the user can be deleted.
|
|
598
|
+
for database in databases:
|
|
599
|
+
with self._connect_to_database(
|
|
600
|
+
database
|
|
601
|
+
) as connection, connection.cursor() as cursor:
|
|
602
|
+
cursor.execute(
|
|
603
|
+
SQL("REASSIGN OWNED BY {} TO {};").format(
|
|
604
|
+
Identifier(user), Identifier(self.user)
|
|
605
|
+
)
|
|
606
|
+
)
|
|
607
|
+
cursor.execute(SQL("DROP OWNED BY {};").format(Identifier(user)))
|
|
608
|
+
|
|
609
|
+
# Delete the user.
|
|
610
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
611
|
+
cursor.execute(SQL("DROP ROLE {};").format(Identifier(user)))
|
|
612
|
+
except psycopg2.Error as e:
|
|
613
|
+
logger.error(f"Failed to delete user: {e}")
|
|
614
|
+
raise PostgreSQLDeleteUserError() from e
|
|
615
|
+
|
|
616
|
+
def grant_internal_access_group_memberships(self) -> None:
|
|
617
|
+
"""Grant membership to the internal access-group to existing internal users."""
|
|
618
|
+
connection = None
|
|
619
|
+
try:
|
|
620
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
621
|
+
for user in self.system_users:
|
|
622
|
+
cursor.execute(
|
|
623
|
+
SQL("GRANT {} TO {};").format(
|
|
624
|
+
Identifier(ACCESS_GROUP_INTERNAL),
|
|
625
|
+
Identifier(user),
|
|
626
|
+
)
|
|
627
|
+
)
|
|
628
|
+
except psycopg2.Error as e:
|
|
629
|
+
logger.error(f"Failed to grant internal access group memberships: {e}")
|
|
630
|
+
raise PostgreSQLAssignGroupError() from e
|
|
631
|
+
finally:
|
|
632
|
+
if connection is not None:
|
|
633
|
+
connection.close()
|
|
634
|
+
|
|
635
|
+
def grant_relation_access_group_memberships(self) -> None:
|
|
636
|
+
"""Grant membership to the relation access-group to existing relation users."""
|
|
637
|
+
rel_users = self.list_users_from_relation()
|
|
638
|
+
if not rel_users:
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
connection = None
|
|
642
|
+
try:
|
|
643
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
644
|
+
rel_groups = SQL(",").join(Identifier(group) for group in [ACCESS_GROUP_RELATION])
|
|
645
|
+
rel_users = SQL(",").join(Identifier(user) for user in rel_users)
|
|
646
|
+
|
|
647
|
+
cursor.execute(
|
|
648
|
+
SQL("GRANT {groups} TO {users};").format(
|
|
649
|
+
groups=rel_groups,
|
|
650
|
+
users=rel_users,
|
|
651
|
+
)
|
|
652
|
+
)
|
|
653
|
+
except psycopg2.Error as e:
|
|
654
|
+
logger.error(f"Failed to grant relation access group memberships: {e}")
|
|
655
|
+
raise PostgreSQLAssignGroupError() from e
|
|
656
|
+
finally:
|
|
657
|
+
if connection is not None:
|
|
658
|
+
connection.close()
|
|
659
|
+
|
|
660
|
+
def grant_replication_privileges(
|
|
661
|
+
self,
|
|
662
|
+
user: str,
|
|
663
|
+
database: str,
|
|
664
|
+
schematables: List[str],
|
|
665
|
+
old_schematables: Optional[List[str]] = None,
|
|
666
|
+
) -> None:
|
|
667
|
+
"""Grant CONNECT privilege on database and SELECT privilege on tables.
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
user: target user for privileges grant.
|
|
671
|
+
database: database to grant CONNECT privilege on.
|
|
672
|
+
schematables: list of tables with schema notation to grant SELECT privileges on.
|
|
673
|
+
old_schematables: list of tables with schema notation to revoke all privileges from.
|
|
674
|
+
"""
|
|
675
|
+
connection = None
|
|
676
|
+
try:
|
|
677
|
+
connection = self._connect_to_database(database=database)
|
|
678
|
+
with connection, connection.cursor() as cursor:
|
|
679
|
+
cursor.execute(
|
|
680
|
+
SQL("GRANT CONNECT ON DATABASE {} TO {};").format(
|
|
681
|
+
Identifier(database), Identifier(user)
|
|
682
|
+
)
|
|
683
|
+
)
|
|
684
|
+
if old_schematables:
|
|
685
|
+
cursor.execute(
|
|
686
|
+
SQL("REVOKE ALL PRIVILEGES ON TABLE {} FROM {};").format(
|
|
687
|
+
SQL(",").join(
|
|
688
|
+
Identifier(schematable.split(".")[0], schematable.split(".")[1])
|
|
689
|
+
for schematable in old_schematables
|
|
690
|
+
),
|
|
691
|
+
Identifier(user),
|
|
692
|
+
)
|
|
693
|
+
)
|
|
694
|
+
cursor.execute(
|
|
695
|
+
SQL("GRANT SELECT ON TABLE {} TO {};").format(
|
|
696
|
+
SQL(",").join(
|
|
697
|
+
Identifier(schematable.split(".")[0], schematable.split(".")[1])
|
|
698
|
+
for schematable in schematables
|
|
699
|
+
),
|
|
700
|
+
Identifier(user),
|
|
701
|
+
)
|
|
702
|
+
)
|
|
703
|
+
finally:
|
|
704
|
+
if connection:
|
|
705
|
+
connection.close()
|
|
706
|
+
|
|
707
|
+
def revoke_replication_privileges(
|
|
708
|
+
self, user: str, database: str, schematables: List[str]
|
|
709
|
+
) -> None:
|
|
710
|
+
"""Revoke all privileges from tables and database.
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
user: target user for privileges revocation.
|
|
714
|
+
database: database to remove all privileges from.
|
|
715
|
+
schematables: list of tables with schema notation to revoke all privileges from.
|
|
716
|
+
"""
|
|
717
|
+
connection = None
|
|
718
|
+
try:
|
|
719
|
+
connection = self._connect_to_database(database=database)
|
|
720
|
+
with connection, connection.cursor() as cursor:
|
|
721
|
+
cursor.execute(
|
|
722
|
+
SQL("REVOKE ALL PRIVILEGES ON TABLE {} FROM {};").format(
|
|
723
|
+
SQL(",").join(
|
|
724
|
+
Identifier(schematable.split(".")[0], schematable.split(".")[1])
|
|
725
|
+
for schematable in schematables
|
|
726
|
+
),
|
|
727
|
+
Identifier(user),
|
|
728
|
+
)
|
|
729
|
+
)
|
|
730
|
+
cursor.execute(
|
|
731
|
+
SQL("REVOKE ALL PRIVILEGES ON DATABASE {} FROM {};").format(
|
|
732
|
+
Identifier(database), Identifier(user)
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
finally:
|
|
736
|
+
if connection:
|
|
737
|
+
connection.close()
|
|
738
|
+
|
|
739
|
+
def enable_disable_extensions(
|
|
740
|
+
self, extensions: Dict[str, bool], database: Optional[str] = None
|
|
741
|
+
) -> None:
|
|
742
|
+
"""Enables or disables a PostgreSQL extension.
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
extensions: the name of the extensions.
|
|
746
|
+
database: optional database where to enable/disable the extension.
|
|
747
|
+
|
|
748
|
+
Raises:
|
|
749
|
+
PostgreSQLEnableDisableExtensionError if the operation fails.
|
|
750
|
+
"""
|
|
751
|
+
connection = None
|
|
752
|
+
try:
|
|
753
|
+
if database is not None:
|
|
754
|
+
databases = [database]
|
|
755
|
+
else:
|
|
756
|
+
# Retrieve all the databases.
|
|
757
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
758
|
+
cursor.execute("SELECT datname FROM pg_database WHERE NOT datistemplate;")
|
|
759
|
+
databases = {database[0] for database in cursor.fetchall()}
|
|
760
|
+
|
|
761
|
+
ordered_extensions = OrderedDict()
|
|
762
|
+
for plugin in DEPENDENCY_PLUGINS:
|
|
763
|
+
ordered_extensions[plugin] = extensions.get(plugin, False)
|
|
764
|
+
for extension, enable in extensions.items():
|
|
765
|
+
ordered_extensions[extension] = enable
|
|
766
|
+
|
|
767
|
+
self._configure_pgaudit(False)
|
|
768
|
+
|
|
769
|
+
# Enable/disabled the extension in each database.
|
|
770
|
+
for database in databases:
|
|
771
|
+
with self._connect_to_database(
|
|
772
|
+
database=database
|
|
773
|
+
) as connection, connection.cursor() as cursor:
|
|
774
|
+
for extension, enable in ordered_extensions.items():
|
|
775
|
+
cursor.execute(
|
|
776
|
+
f"CREATE EXTENSION IF NOT EXISTS {extension};"
|
|
777
|
+
if enable
|
|
778
|
+
else f"DROP EXTENSION IF EXISTS {extension};"
|
|
779
|
+
)
|
|
780
|
+
self._configure_pgaudit(ordered_extensions.get("pgaudit", False))
|
|
781
|
+
except psycopg2.errors.UniqueViolation:
|
|
782
|
+
pass
|
|
783
|
+
except psycopg2.errors.DependentObjectsStillExist:
|
|
784
|
+
raise
|
|
785
|
+
except psycopg2.Error as e:
|
|
786
|
+
raise PostgreSQLEnableDisableExtensionError() from e
|
|
787
|
+
finally:
|
|
788
|
+
if connection is not None:
|
|
789
|
+
connection.close()
|
|
790
|
+
|
|
791
|
+
def get_last_archived_wal(self) -> str:
|
|
792
|
+
"""Get the name of the last archived wal for the current PostgreSQL cluster."""
|
|
793
|
+
try:
|
|
794
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
795
|
+
# Should always be present
|
|
796
|
+
cursor.execute("SELECT last_archived_wal FROM pg_stat_archiver;")
|
|
797
|
+
return cursor.fetchone()[0] # type: ignore
|
|
798
|
+
except psycopg2.Error as e:
|
|
799
|
+
logger.error(f"Failed to get PostgreSQL last archived WAL: {e}")
|
|
800
|
+
raise PostgreSQLGetLastArchivedWALError() from e
|
|
801
|
+
|
|
802
|
+
def get_current_timeline(self) -> str:
|
|
803
|
+
"""Get the timeline id for the current PostgreSQL unit."""
|
|
804
|
+
try:
|
|
805
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
806
|
+
cursor.execute("SELECT timeline_id FROM pg_control_checkpoint();")
|
|
807
|
+
# There should always be a timeline
|
|
808
|
+
return cursor.fetchone()[0] # type: ignore
|
|
809
|
+
except psycopg2.Error as e:
|
|
810
|
+
logger.error(f"Failed to get PostgreSQL current timeline id: {e}")
|
|
811
|
+
raise PostgreSQLGetCurrentTimelineError() from e
|
|
812
|
+
|
|
813
|
+
def get_postgresql_text_search_configs(self) -> Set[str]:
|
|
814
|
+
"""Returns the PostgreSQL available text search configs.
|
|
815
|
+
|
|
816
|
+
Returns:
|
|
817
|
+
Set of PostgreSQL text search configs.
|
|
818
|
+
"""
|
|
819
|
+
with self._connect_to_database(
|
|
820
|
+
database_host=self.current_host
|
|
821
|
+
) as connection, connection.cursor() as cursor:
|
|
822
|
+
cursor.execute("SELECT CONCAT('pg_catalog.', cfgname) FROM pg_ts_config;")
|
|
823
|
+
text_search_configs = cursor.fetchall()
|
|
824
|
+
return {text_search_config[0] for text_search_config in text_search_configs}
|
|
825
|
+
|
|
826
|
+
def get_postgresql_timezones(self) -> Set[str]:
|
|
827
|
+
"""Returns the PostgreSQL available timezones.
|
|
828
|
+
|
|
829
|
+
Returns:
|
|
830
|
+
Set of PostgreSQL timezones.
|
|
831
|
+
"""
|
|
832
|
+
with self._connect_to_database(
|
|
833
|
+
database_host=self.current_host
|
|
834
|
+
) as connection, connection.cursor() as cursor:
|
|
835
|
+
cursor.execute("SELECT name FROM pg_timezone_names;")
|
|
836
|
+
timezones = cursor.fetchall()
|
|
837
|
+
return {timezone[0] for timezone in timezones}
|
|
838
|
+
|
|
839
|
+
def get_postgresql_default_table_access_methods(self) -> Set[str]:
|
|
840
|
+
"""Returns the PostgreSQL available table access methods.
|
|
841
|
+
|
|
842
|
+
Returns:
|
|
843
|
+
Set of PostgreSQL table access methods.
|
|
844
|
+
"""
|
|
845
|
+
with self._connect_to_database(
|
|
846
|
+
database_host=self.current_host
|
|
847
|
+
) as connection, connection.cursor() as cursor:
|
|
848
|
+
cursor.execute("SELECT amname FROM pg_am WHERE amtype = 't';")
|
|
849
|
+
access_methods = cursor.fetchall()
|
|
850
|
+
return {access_method[0] for access_method in access_methods}
|
|
851
|
+
|
|
852
|
+
def get_postgresql_version(self, current_host=True) -> str:
|
|
853
|
+
"""Returns the PostgreSQL version.
|
|
854
|
+
|
|
855
|
+
Returns:
|
|
856
|
+
PostgreSQL version number.
|
|
857
|
+
"""
|
|
858
|
+
host = self.current_host if current_host else None
|
|
859
|
+
try:
|
|
860
|
+
with self._connect_to_database(
|
|
861
|
+
database_host=host
|
|
862
|
+
) as connection, connection.cursor() as cursor:
|
|
863
|
+
cursor.execute("SELECT version();")
|
|
864
|
+
# Split to get only the version number. There should always be a version.
|
|
865
|
+
return cursor.fetchone()[0].split(" ")[1] # type:ignore
|
|
866
|
+
except psycopg2.Error as e:
|
|
867
|
+
logger.error(f"Failed to get PostgreSQL version: {e}")
|
|
868
|
+
raise PostgreSQLGetPostgreSQLVersionError() from e
|
|
869
|
+
|
|
870
|
+
def is_tls_enabled(self, check_current_host: bool = False) -> bool:
|
|
871
|
+
"""Returns whether TLS is enabled.
|
|
872
|
+
|
|
873
|
+
Args:
|
|
874
|
+
check_current_host: whether to check the current host
|
|
875
|
+
instead of the primary host.
|
|
876
|
+
|
|
877
|
+
Returns:
|
|
878
|
+
whether TLS is enabled.
|
|
879
|
+
"""
|
|
880
|
+
try:
|
|
881
|
+
with self._connect_to_database(
|
|
882
|
+
database_host=self.current_host if check_current_host else None
|
|
883
|
+
) as connection, connection.cursor() as cursor:
|
|
884
|
+
cursor.execute("SHOW ssl;")
|
|
885
|
+
# SSL state should always be set
|
|
886
|
+
return "on" in cursor.fetchone()[0] # type: ignore
|
|
887
|
+
except psycopg2.Error:
|
|
888
|
+
# Connection errors happen when PostgreSQL has not started yet.
|
|
889
|
+
return False
|
|
890
|
+
|
|
891
|
+
def list_access_groups(self, current_host=False) -> Set[str]:
|
|
892
|
+
"""Returns the list of PostgreSQL database access groups.
|
|
893
|
+
|
|
894
|
+
Args:
|
|
895
|
+
current_host: whether to check the current host
|
|
896
|
+
instead of the primary host.
|
|
897
|
+
|
|
898
|
+
Returns:
|
|
899
|
+
List of PostgreSQL database access groups.
|
|
900
|
+
"""
|
|
901
|
+
connection = None
|
|
902
|
+
host = self.current_host if current_host else None
|
|
903
|
+
try:
|
|
904
|
+
with self._connect_to_database(
|
|
905
|
+
database_host=host
|
|
906
|
+
) as connection, connection.cursor() as cursor:
|
|
907
|
+
cursor.execute(
|
|
908
|
+
"SELECT groname FROM pg_catalog.pg_group WHERE groname LIKE '%_access';"
|
|
909
|
+
)
|
|
910
|
+
access_groups = cursor.fetchall()
|
|
911
|
+
return {group[0] for group in access_groups}
|
|
912
|
+
except psycopg2.Error as e:
|
|
913
|
+
logger.error(f"Failed to list PostgreSQL database access groups: {e}")
|
|
914
|
+
raise PostgreSQLListGroupsError() from e
|
|
915
|
+
finally:
|
|
916
|
+
if connection is not None:
|
|
917
|
+
connection.close()
|
|
918
|
+
|
|
919
|
+
def list_accessible_databases_for_user(self, user: str, current_host=False) -> Set[str]:
|
|
920
|
+
"""Returns the list of accessible databases for a specific user.
|
|
921
|
+
|
|
922
|
+
Args:
|
|
923
|
+
user: the user to check.
|
|
924
|
+
current_host: whether to check the current host
|
|
925
|
+
instead of the primary host.
|
|
926
|
+
|
|
927
|
+
Returns:
|
|
928
|
+
List of accessible database (the ones where
|
|
929
|
+
the user has the CONNECT privilege).
|
|
930
|
+
"""
|
|
931
|
+
connection = None
|
|
932
|
+
host = self.current_host if current_host else None
|
|
933
|
+
try:
|
|
934
|
+
with self._connect_to_database(
|
|
935
|
+
database_host=host
|
|
936
|
+
) as connection, connection.cursor() as cursor:
|
|
937
|
+
cursor.execute(
|
|
938
|
+
SQL(
|
|
939
|
+
"SELECT TRUE FROM pg_catalog.pg_user WHERE usename = {} AND usesuper;"
|
|
940
|
+
).format(Literal(user))
|
|
941
|
+
)
|
|
942
|
+
if cursor.fetchone() is not None:
|
|
943
|
+
return {"all"}
|
|
944
|
+
cursor.execute(
|
|
945
|
+
SQL(
|
|
946
|
+
"SELECT datname FROM pg_catalog.pg_database WHERE has_database_privilege({}, datname, 'CONNECT') AND NOT datistemplate;"
|
|
947
|
+
).format(Literal(user))
|
|
948
|
+
)
|
|
949
|
+
databases = cursor.fetchall()
|
|
950
|
+
return {database[0] for database in databases}
|
|
951
|
+
except psycopg2.Error as e:
|
|
952
|
+
logger.error(f"Failed to list accessible databases for user {user}: {e}")
|
|
953
|
+
raise PostgreSQLListAccessibleDatabasesForUserError() from e
|
|
954
|
+
finally:
|
|
955
|
+
if connection is not None:
|
|
956
|
+
connection.close()
|
|
957
|
+
|
|
958
|
+
def list_users(self, group: Optional[str] = None, current_host=False) -> Set[str]:
|
|
959
|
+
"""Returns the list of PostgreSQL database users.
|
|
960
|
+
|
|
961
|
+
Args:
|
|
962
|
+
group: optional group to filter the users.
|
|
963
|
+
current_host: whether to check the current host
|
|
964
|
+
instead of the primary host.
|
|
965
|
+
|
|
966
|
+
Returns:
|
|
967
|
+
List of PostgreSQL database users.
|
|
968
|
+
"""
|
|
969
|
+
connection = None
|
|
970
|
+
host = self.current_host if current_host else None
|
|
971
|
+
try:
|
|
972
|
+
with self._connect_to_database(
|
|
973
|
+
database_host=host
|
|
974
|
+
) as connection, connection.cursor() as cursor:
|
|
975
|
+
if group:
|
|
976
|
+
query = SQL(
|
|
977
|
+
"SELECT usename FROM (SELECT UNNEST(grolist) AS user_id FROM pg_catalog.pg_group WHERE groname = {}) AS g JOIN pg_catalog.pg_user AS u ON g.user_id = u.usesysid;"
|
|
978
|
+
).format(Literal(group))
|
|
979
|
+
else:
|
|
980
|
+
query = "SELECT usename FROM pg_catalog.pg_user;"
|
|
981
|
+
cursor.execute(query)
|
|
982
|
+
usernames = cursor.fetchall()
|
|
983
|
+
return {username[0] for username in usernames}
|
|
984
|
+
except psycopg2.Error as e:
|
|
985
|
+
logger.error(f"Failed to list PostgreSQL database users: {e}")
|
|
986
|
+
raise PostgreSQLListUsersError() from e
|
|
987
|
+
finally:
|
|
988
|
+
if connection is not None:
|
|
989
|
+
connection.close()
|
|
990
|
+
|
|
991
|
+
def list_users_from_relation(self, current_host=False) -> Set[str]:
|
|
992
|
+
"""Returns the list of PostgreSQL database users that were created by a relation.
|
|
993
|
+
|
|
994
|
+
Args:
|
|
995
|
+
current_host: whether to check the current host
|
|
996
|
+
instead of the primary host.
|
|
997
|
+
|
|
998
|
+
Returns:
|
|
999
|
+
List of PostgreSQL database users.
|
|
1000
|
+
"""
|
|
1001
|
+
connection = None
|
|
1002
|
+
host = self.current_host if current_host else None
|
|
1003
|
+
try:
|
|
1004
|
+
with self._connect_to_database(
|
|
1005
|
+
database_host=host
|
|
1006
|
+
) as connection, connection.cursor() as cursor:
|
|
1007
|
+
cursor.execute(
|
|
1008
|
+
"SELECT usename "
|
|
1009
|
+
"FROM pg_catalog.pg_user "
|
|
1010
|
+
"WHERE usename LIKE 'relation_id_%' OR usename LIKE 'relation-%' "
|
|
1011
|
+
"OR usename LIKE 'pgbouncer_auth_relation_%' OR usename LIKE '%_user_%_%' "
|
|
1012
|
+
"OR usename LIKE 'logical_replication_relation_%';"
|
|
1013
|
+
)
|
|
1014
|
+
usernames = cursor.fetchall()
|
|
1015
|
+
return {username[0] for username in usernames}
|
|
1016
|
+
except psycopg2.Error as e:
|
|
1017
|
+
logger.error(f"Failed to list PostgreSQL database users: {e}")
|
|
1018
|
+
raise PostgreSQLListUsersError() from e
|
|
1019
|
+
finally:
|
|
1020
|
+
if connection is not None:
|
|
1021
|
+
connection.close()
|
|
1022
|
+
|
|
1023
|
+
def list_existing_roles(self) -> Set[str]:
|
|
1024
|
+
"""Returns a set containing the existing roles.
|
|
1025
|
+
|
|
1026
|
+
Returns:
|
|
1027
|
+
Set containing the existing roles.
|
|
1028
|
+
"""
|
|
1029
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
1030
|
+
cursor.execute("SELECT rolname FROM pg_roles;")
|
|
1031
|
+
return {role[0] for role in cursor.fetchall() if role[0]}
|
|
1032
|
+
|
|
1033
|
+
def list_valid_privileges_and_roles(self) -> Tuple[Set[str], Set[str]]:
|
|
1034
|
+
"""Returns two sets with valid privileges and roles.
|
|
1035
|
+
|
|
1036
|
+
Returns:
|
|
1037
|
+
Tuple containing two sets: the first with valid privileges
|
|
1038
|
+
and the second with valid roles.
|
|
1039
|
+
"""
|
|
1040
|
+
return {
|
|
1041
|
+
"superuser",
|
|
1042
|
+
}, ALLOWED_ROLES
|
|
1043
|
+
|
|
1044
|
+
def _get_existing_databases(self) -> List[str]:
|
|
1045
|
+
# Template1 should go first
|
|
1046
|
+
databases = ["template1"]
|
|
1047
|
+
connection = None
|
|
1048
|
+
cursor = None
|
|
1049
|
+
try:
|
|
1050
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
1051
|
+
cursor.execute(
|
|
1052
|
+
"SELECT datname FROM pg_database WHERE datname <> 'template0' AND datname <> 'template1';"
|
|
1053
|
+
)
|
|
1054
|
+
db = cursor.fetchone()
|
|
1055
|
+
while db:
|
|
1056
|
+
databases.append(db[0])
|
|
1057
|
+
db = cursor.fetchone()
|
|
1058
|
+
finally:
|
|
1059
|
+
if cursor:
|
|
1060
|
+
cursor.close()
|
|
1061
|
+
if connection:
|
|
1062
|
+
connection.close()
|
|
1063
|
+
return databases
|
|
1064
|
+
|
|
1065
|
+
def set_up_database(self, temp_location: Optional[str] = None) -> None:
|
|
1066
|
+
"""Set up postgres database with the right permissions."""
|
|
1067
|
+
connection = None
|
|
1068
|
+
cursor = None
|
|
1069
|
+
try:
|
|
1070
|
+
connection = self._connect_to_database()
|
|
1071
|
+
cursor = connection.cursor()
|
|
1072
|
+
|
|
1073
|
+
if temp_location is not None:
|
|
1074
|
+
cursor.execute("SELECT TRUE FROM pg_tablespace WHERE spcname='temp';")
|
|
1075
|
+
if cursor.fetchone() is None:
|
|
1076
|
+
cursor.execute(f"CREATE TABLESPACE temp LOCATION '{temp_location}';")
|
|
1077
|
+
cursor.execute("GRANT CREATE ON TABLESPACE temp TO public;")
|
|
1078
|
+
|
|
1079
|
+
cursor.close()
|
|
1080
|
+
cursor = None
|
|
1081
|
+
connection.close()
|
|
1082
|
+
connection = None
|
|
1083
|
+
|
|
1084
|
+
with self._connect_to_database(
|
|
1085
|
+
database="template1"
|
|
1086
|
+
) as connection, connection.cursor() as cursor:
|
|
1087
|
+
cursor.execute(
|
|
1088
|
+
f"SELECT TRUE FROM pg_roles WHERE rolname='{ROLE_DATABASES_OWNER}';" # noqa: S608
|
|
1089
|
+
)
|
|
1090
|
+
if cursor.fetchone() is None:
|
|
1091
|
+
self.create_user(
|
|
1092
|
+
ROLE_DATABASES_OWNER,
|
|
1093
|
+
can_create_database=True,
|
|
1094
|
+
extra_user_roles=[ROLE_DML],
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
self.set_up_login_hook_function()
|
|
1098
|
+
self.set_up_predefined_catalog_roles_function()
|
|
1099
|
+
|
|
1100
|
+
connection.close()
|
|
1101
|
+
connection = None
|
|
1102
|
+
|
|
1103
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
1104
|
+
cursor.execute("REVOKE ALL PRIVILEGES ON DATABASE postgres FROM PUBLIC;")
|
|
1105
|
+
cursor.execute("REVOKE CREATE ON SCHEMA public FROM PUBLIC;")
|
|
1106
|
+
for user in self.system_users:
|
|
1107
|
+
cursor.execute(
|
|
1108
|
+
SQL("GRANT ALL PRIVILEGES ON DATABASE postgres TO {};").format(
|
|
1109
|
+
Identifier(user)
|
|
1110
|
+
)
|
|
1111
|
+
)
|
|
1112
|
+
except psycopg2.Error as e:
|
|
1113
|
+
logger.error(f"Failed to set up databases: {e}")
|
|
1114
|
+
raise PostgreSQLDatabasesSetupError() from e
|
|
1115
|
+
finally:
|
|
1116
|
+
if cursor is not None:
|
|
1117
|
+
cursor.close()
|
|
1118
|
+
if connection is not None:
|
|
1119
|
+
connection.close()
|
|
1120
|
+
|
|
1121
|
+
def set_up_login_hook_function(self) -> None:
|
|
1122
|
+
"""Create a login hook function to set the user for the current session."""
|
|
1123
|
+
function_creation_statement = f"""CREATE OR REPLACE FUNCTION login_hook.login() RETURNS VOID AS $$
|
|
1124
|
+
DECLARE
|
|
1125
|
+
ex_state TEXT;
|
|
1126
|
+
ex_message TEXT;
|
|
1127
|
+
ex_detail TEXT;
|
|
1128
|
+
ex_hint TEXT;
|
|
1129
|
+
ex_context TEXT;
|
|
1130
|
+
cur_user TEXT;
|
|
1131
|
+
db_admin_role TEXT;
|
|
1132
|
+
db_name TEXT;
|
|
1133
|
+
db_owner_role TEXT;
|
|
1134
|
+
is_user_admin BOOLEAN;
|
|
1135
|
+
user_has_createdb BOOLEAN;
|
|
1136
|
+
BEGIN
|
|
1137
|
+
IF NOT login_hook.is_executing_login_hook()
|
|
1138
|
+
THEN
|
|
1139
|
+
RAISE EXCEPTION 'The login_hook.login() function should only be invoked by the login_hook code';
|
|
1140
|
+
END IF;
|
|
1141
|
+
|
|
1142
|
+
cur_user := (SELECT current_user);
|
|
1143
|
+
|
|
1144
|
+
EXECUTE 'SELECT current_database()' INTO db_name;
|
|
1145
|
+
db_admin_role = 'charmed_' || db_name || '_admin';
|
|
1146
|
+
|
|
1147
|
+
EXECUTE format('SELECT EXISTS(SELECT * FROM pg_auth_members a, pg_roles b, pg_roles c WHERE a.roleid = b.oid AND a.member = c.oid AND (b.rolname = %L OR b.rolname = %L) and c.rolname = %L)', db_admin_role, '{ROLE_ADMIN}', cur_user) INTO is_user_admin;
|
|
1148
|
+
|
|
1149
|
+
EXECUTE format('SELECT EXISTS(SELECT * FROM pg_auth_members a, pg_roles b, pg_roles c WHERE a.roleid = b.oid AND a.member = c.oid AND b.rolname = %L and c.rolname = %L)', '{ROLE_DATABASES_OWNER}', cur_user) INTO user_has_createdb;
|
|
1150
|
+
|
|
1151
|
+
BEGIN
|
|
1152
|
+
IF is_user_admin = true THEN
|
|
1153
|
+
db_owner_role = 'charmed_' || db_name || '_owner';
|
|
1154
|
+
EXECUTE format('SET ROLE %L', db_owner_role);
|
|
1155
|
+
ELSE
|
|
1156
|
+
IF user_has_createdb = true THEN
|
|
1157
|
+
EXECUTE format('SET ROLE %L', '{ROLE_DATABASES_OWNER}');
|
|
1158
|
+
END IF;
|
|
1159
|
+
END IF;
|
|
1160
|
+
EXCEPTION
|
|
1161
|
+
WHEN OTHERS THEN
|
|
1162
|
+
GET STACKED DIAGNOSTICS ex_state = RETURNED_SQLSTATE, ex_message = MESSAGE_TEXT, ex_detail = PG_EXCEPTION_DETAIL, ex_hint = PG_EXCEPTION_HINT, ex_context = PG_EXCEPTION_CONTEXT;
|
|
1163
|
+
RAISE LOG e'Error in login_hook.login()\nsqlstate %\nmessage: %\ndetail: %\nhint: %\ncontext: %', ex_state, ex_message, ex_detail, ex_hint, ex_context;
|
|
1164
|
+
END;
|
|
1165
|
+
END;
|
|
1166
|
+
$$ LANGUAGE plpgsql;""" # noqa: S608
|
|
1167
|
+
connection = None
|
|
1168
|
+
try:
|
|
1169
|
+
for database in self._get_existing_databases():
|
|
1170
|
+
with self._connect_to_database(
|
|
1171
|
+
database=database
|
|
1172
|
+
) as connection, connection.cursor() as cursor:
|
|
1173
|
+
cursor.execute(SQL("CREATE EXTENSION IF NOT EXISTS login_hook;"))
|
|
1174
|
+
cursor.execute(SQL("CREATE SCHEMA IF NOT EXISTS login_hook;"))
|
|
1175
|
+
cursor.execute(SQL(function_creation_statement))
|
|
1176
|
+
cursor.execute(SQL("GRANT EXECUTE ON FUNCTION login_hook.login() TO PUBLIC;"))
|
|
1177
|
+
except psycopg2.Error as e:
|
|
1178
|
+
logger.error(f"Failed to create login hook function: {e}")
|
|
1179
|
+
raise e
|
|
1180
|
+
finally:
|
|
1181
|
+
if connection:
|
|
1182
|
+
connection.close()
|
|
1183
|
+
|
|
1184
|
+
def set_up_predefined_catalog_roles_function(self) -> None:
|
|
1185
|
+
"""Create predefined catalog roles function."""
|
|
1186
|
+
function_creation_statement = f"""CREATE OR REPLACE FUNCTION set_up_predefined_catalog_roles() RETURNS VOID AS $$
|
|
1187
|
+
DECLARE
|
|
1188
|
+
database TEXT;
|
|
1189
|
+
current_session_user TEXT;
|
|
1190
|
+
owner_user TEXT;
|
|
1191
|
+
admin_user TEXT;
|
|
1192
|
+
dml_user TEXT;
|
|
1193
|
+
statements TEXT[];
|
|
1194
|
+
statement TEXT;
|
|
1195
|
+
BEGIN
|
|
1196
|
+
database := (SELECT current_database());
|
|
1197
|
+
current_session_user := (SELECT session_user);
|
|
1198
|
+
owner_user := quote_ident('charmed_' || database || '_owner');
|
|
1199
|
+
admin_user := quote_ident('charmed_' || database || '_admin');
|
|
1200
|
+
dml_user := quote_ident('charmed_' || database || '_dml');
|
|
1201
|
+
|
|
1202
|
+
IF (SELECT COUNT(rolname) FROM pg_roles WHERE rolname=FORMAT('%s', 'charmed_' || database || '_owner')) = 0 THEN
|
|
1203
|
+
statements := ARRAY[
|
|
1204
|
+
'CREATE ROLE ' || owner_user || ' NOSUPERUSER NOCREATEDB NOCREATEROLE NOLOGIN NOREPLICATION;',
|
|
1205
|
+
'CREATE ROLE ' || admin_user || ' NOSUPERUSER NOCREATEDB NOCREATEROLE NOLOGIN NOREPLICATION NOINHERIT IN ROLE ' || owner_user || ';',
|
|
1206
|
+
'CREATE ROLE ' || dml_user || ' NOSUPERUSER NOCREATEDB NOCREATEROLE NOLOGIN NOREPLICATION;',
|
|
1207
|
+
'GRANT ' || owner_user || ' TO {ROLE_ADMIN} WITH INHERIT FALSE;'
|
|
1208
|
+
];
|
|
1209
|
+
FOREACH statement IN ARRAY statements
|
|
1210
|
+
LOOP
|
|
1211
|
+
EXECUTE statement;
|
|
1212
|
+
END LOOP;
|
|
1213
|
+
END IF;
|
|
1214
|
+
|
|
1215
|
+
database := quote_ident(database);
|
|
1216
|
+
|
|
1217
|
+
statements := ARRAY[
|
|
1218
|
+
'REVOKE CREATE ON DATABASE ' || database || ' FROM {ROLE_DATABASES_OWNER};',
|
|
1219
|
+
'ALTER SCHEMA public OWNER TO ' || owner_user || ';',
|
|
1220
|
+
'GRANT CONNECT ON DATABASE ' || database || ' TO ' || admin_user || ';',
|
|
1221
|
+
'GRANT CONNECT ON DATABASE ' || database || ' TO {ROLE_STATS};',
|
|
1222
|
+
'GRANT CONNECT ON DATABASE ' || database || ' TO {ROLE_READ};',
|
|
1223
|
+
'GRANT CONNECT ON DATABASE ' || database || ' TO {ROLE_DML};',
|
|
1224
|
+
'GRANT CONNECT ON DATABASE ' || database || ' TO {ROLE_BACKUP};',
|
|
1225
|
+
'GRANT CONNECT ON DATABASE ' || database || ' TO {ROLE_DBA};',
|
|
1226
|
+
'GRANT CONNECT ON DATABASE ' || database || ' TO {ROLE_ADMIN};',
|
|
1227
|
+
'GRANT ' || admin_user || ' TO {ROLE_ADMIN} WITH INHERIT FALSE;',
|
|
1228
|
+
'GRANT CONNECT ON DATABASE ' || database || ' TO {ROLE_DATABASES_OWNER};'
|
|
1229
|
+
];
|
|
1230
|
+
FOREACH statement IN ARRAY statements
|
|
1231
|
+
LOOP
|
|
1232
|
+
EXECUTE statement;
|
|
1233
|
+
END LOOP;
|
|
1234
|
+
|
|
1235
|
+
IF current_session_user LIKE 'relation-%' OR current_session_user LIKE 'relation_id_%' THEN
|
|
1236
|
+
RAISE NOTICE 'Granting % to %', admin_user, current_session_user;
|
|
1237
|
+
statements := ARRAY[
|
|
1238
|
+
'GRANT ' || admin_user || ' TO "' || current_session_user || '" WITH INHERIT FALSE;',
|
|
1239
|
+
'GRANT ' || dml_user || ' TO "' || current_session_user || '" WITH INHERIT TRUE;'
|
|
1240
|
+
];
|
|
1241
|
+
FOREACH statement IN ARRAY statements
|
|
1242
|
+
LOOP
|
|
1243
|
+
EXECUTE statement;
|
|
1244
|
+
END LOOP;
|
|
1245
|
+
END IF;
|
|
1246
|
+
|
|
1247
|
+
statements := ARRAY[
|
|
1248
|
+
'GRANT CREATE ON DATABASE ' || database || ' TO ' || owner_user || ';',
|
|
1249
|
+
'GRANT TEMPORARY ON DATABASE ' || database || ' TO ' || owner_user || ';',
|
|
1250
|
+
'ALTER DEFAULT PRIVILEGES FOR ROLE ' || owner_user || ' GRANT SELECT ON TABLES TO ' || admin_user || ';',
|
|
1251
|
+
'ALTER DEFAULT PRIVILEGES FOR ROLE ' || owner_user || ' GRANT EXECUTE ON FUNCTIONS TO ' || admin_user || ';',
|
|
1252
|
+
'ALTER DEFAULT PRIVILEGES FOR ROLE ' || owner_user || ' GRANT SELECT ON SEQUENCES TO ' || admin_user || ';',
|
|
1253
|
+
'GRANT EXECUTE ON FUNCTION set_user_u(text) TO charmed_dba;',
|
|
1254
|
+
'REVOKE EXECUTE ON FUNCTION set_user_u(text) FROM ' || owner_user || ';',
|
|
1255
|
+
'REVOKE EXECUTE ON FUNCTION set_user_u(text) FROM ' || admin_user || ';',
|
|
1256
|
+
'GRANT EXECUTE ON FUNCTION set_user(text) TO charmed_dba;',
|
|
1257
|
+
'REVOKE EXECUTE ON FUNCTION set_user(text) FROM ' || owner_user || ';',
|
|
1258
|
+
'REVOKE EXECUTE ON FUNCTION set_user(text) FROM ' || admin_user || ';',
|
|
1259
|
+
'GRANT EXECUTE ON FUNCTION set_user(text, text) TO charmed_dba;',
|
|
1260
|
+
'REVOKE EXECUTE ON FUNCTION set_user(text, text) FROM ' || owner_user || ';',
|
|
1261
|
+
'REVOKE EXECUTE ON FUNCTION set_user(text, text) FROM ' || admin_user || ';',
|
|
1262
|
+
'GRANT EXECUTE ON FUNCTION reset_user() TO charmed_dba;',
|
|
1263
|
+
'REVOKE EXECUTE ON FUNCTION reset_user() FROM ' || owner_user || ';',
|
|
1264
|
+
'REVOKE EXECUTE ON FUNCTION reset_user() FROM ' || admin_user || ';',
|
|
1265
|
+
'GRANT EXECUTE ON FUNCTION reset_user(text) TO charmed_dba;',
|
|
1266
|
+
'REVOKE EXECUTE ON FUNCTION reset_user(text) FROM ' || owner_user || ';',
|
|
1267
|
+
'REVOKE EXECUTE ON FUNCTION reset_user(text) FROM ' || admin_user || ';'
|
|
1268
|
+
];
|
|
1269
|
+
FOREACH statement IN ARRAY statements
|
|
1270
|
+
LOOP
|
|
1271
|
+
EXECUTE statement;
|
|
1272
|
+
END LOOP;
|
|
1273
|
+
END;
|
|
1274
|
+
$$ LANGUAGE plpgsql security definer;""" # noqa: S608
|
|
1275
|
+
connection = None
|
|
1276
|
+
try:
|
|
1277
|
+
for database in self._get_existing_databases():
|
|
1278
|
+
with self._connect_to_database(
|
|
1279
|
+
database=database
|
|
1280
|
+
) as connection, connection.cursor() as cursor:
|
|
1281
|
+
cursor.execute(SQL(function_creation_statement))
|
|
1282
|
+
cursor.execute(
|
|
1283
|
+
SQL("ALTER FUNCTION set_up_predefined_catalog_roles OWNER TO operator;")
|
|
1284
|
+
)
|
|
1285
|
+
cursor.execute(
|
|
1286
|
+
SQL(
|
|
1287
|
+
"REVOKE EXECUTE ON FUNCTION set_up_predefined_catalog_roles FROM PUBLIC;"
|
|
1288
|
+
)
|
|
1289
|
+
)
|
|
1290
|
+
cursor.execute(
|
|
1291
|
+
SQL(
|
|
1292
|
+
"GRANT EXECUTE ON FUNCTION set_up_predefined_catalog_roles TO {};"
|
|
1293
|
+
).format(Identifier(ROLE_DATABASES_OWNER))
|
|
1294
|
+
)
|
|
1295
|
+
cursor.execute(
|
|
1296
|
+
SQL("REVOKE CREATE ON DATABASE {} FROM {};").format(
|
|
1297
|
+
Identifier("template1"), Identifier(ROLE_DATABASES_OWNER)
|
|
1298
|
+
)
|
|
1299
|
+
)
|
|
1300
|
+
except psycopg2.Error as e:
|
|
1301
|
+
logger.error(f"Failed to set up predefined catalog roles function: {e}")
|
|
1302
|
+
raise PostgreSQLCreatePredefinedRolesError() from e
|
|
1303
|
+
finally:
|
|
1304
|
+
if connection:
|
|
1305
|
+
connection.close()
|
|
1306
|
+
|
|
1307
|
+
def update_user_password(
|
|
1308
|
+
self, username: str, password: str, database_host: Optional[str] = None
|
|
1309
|
+
) -> None:
|
|
1310
|
+
"""Update a user password.
|
|
1311
|
+
|
|
1312
|
+
Args:
|
|
1313
|
+
username: the user to update the password.
|
|
1314
|
+
password: the new password for the user.
|
|
1315
|
+
database_host: the host to connect to.
|
|
1316
|
+
|
|
1317
|
+
Raises:
|
|
1318
|
+
PostgreSQLUpdateUserPasswordError if the password couldn't be changed.
|
|
1319
|
+
"""
|
|
1320
|
+
connection = None
|
|
1321
|
+
try:
|
|
1322
|
+
with self._connect_to_database(
|
|
1323
|
+
database_host=database_host
|
|
1324
|
+
) as connection, connection.cursor() as cursor:
|
|
1325
|
+
cursor.execute(SQL("RESET ROLE;"))
|
|
1326
|
+
cursor.execute(SQL("BEGIN;"))
|
|
1327
|
+
cursor.execute(SQL("SET LOCAL log_statement = 'none';"))
|
|
1328
|
+
cursor.execute(
|
|
1329
|
+
SQL("ALTER USER {} WITH ENCRYPTED PASSWORD '" + password + "';").format(
|
|
1330
|
+
Identifier(username)
|
|
1331
|
+
)
|
|
1332
|
+
)
|
|
1333
|
+
cursor.execute(SQL("COMMIT;"))
|
|
1334
|
+
except psycopg2.Error as e:
|
|
1335
|
+
logger.error(f"Failed to update user password: {e}")
|
|
1336
|
+
raise PostgreSQLUpdateUserPasswordError() from e
|
|
1337
|
+
finally:
|
|
1338
|
+
if connection is not None:
|
|
1339
|
+
connection.close()
|
|
1340
|
+
|
|
1341
|
+
def database_exists(self, db: str) -> bool:
|
|
1342
|
+
"""Check whether specified database exists."""
|
|
1343
|
+
connection = None
|
|
1344
|
+
try:
|
|
1345
|
+
connection = self._connect_to_database()
|
|
1346
|
+
with connection, connection.cursor() as cursor:
|
|
1347
|
+
cursor.execute(
|
|
1348
|
+
SQL("SELECT datname FROM pg_database WHERE datname={};").format(Literal(db))
|
|
1349
|
+
)
|
|
1350
|
+
return cursor.fetchone() is not None
|
|
1351
|
+
except psycopg2.Error as e:
|
|
1352
|
+
logger.error(f"Failed to check Postgresql database existence: {e}")
|
|
1353
|
+
raise PostgreSQLDatabaseExistsError() from e
|
|
1354
|
+
finally:
|
|
1355
|
+
if connection:
|
|
1356
|
+
connection.close()
|
|
1357
|
+
|
|
1358
|
+
def table_exists(self, db: str, schema: str, table: str) -> bool:
|
|
1359
|
+
"""Check whether specified table in database exists."""
|
|
1360
|
+
connection = None
|
|
1361
|
+
try:
|
|
1362
|
+
connection = self._connect_to_database(database=db)
|
|
1363
|
+
with connection, connection.cursor() as cursor:
|
|
1364
|
+
cursor.execute(
|
|
1365
|
+
SQL(
|
|
1366
|
+
"SELECT tablename FROM pg_tables WHERE schemaname={} AND tablename={};"
|
|
1367
|
+
).format(Literal(schema), Literal(table))
|
|
1368
|
+
)
|
|
1369
|
+
return cursor.fetchone() is not None
|
|
1370
|
+
except psycopg2.Error as e:
|
|
1371
|
+
logger.error(f"Failed to check Postgresql table existence: {e}")
|
|
1372
|
+
raise PostgreSQLTableExistsError() from e
|
|
1373
|
+
finally:
|
|
1374
|
+
if connection:
|
|
1375
|
+
connection.close()
|
|
1376
|
+
|
|
1377
|
+
def is_table_empty(self, db: str, schema: str, table: str) -> bool:
|
|
1378
|
+
"""Check whether table is empty."""
|
|
1379
|
+
connection = None
|
|
1380
|
+
try:
|
|
1381
|
+
connection = self._connect_to_database(database=db)
|
|
1382
|
+
with connection, connection.cursor() as cursor:
|
|
1383
|
+
cursor.execute(SQL("SELECT COUNT(1) FROM {};").format(Identifier(schema, table)))
|
|
1384
|
+
if result := cursor.fetchone():
|
|
1385
|
+
return result[0] == 0
|
|
1386
|
+
return True
|
|
1387
|
+
except psycopg2.Error as e:
|
|
1388
|
+
logger.error(f"Failed to check whether table is empty: {e}")
|
|
1389
|
+
raise PostgreSQLIsTableEmptyError() from e
|
|
1390
|
+
finally:
|
|
1391
|
+
if connection:
|
|
1392
|
+
connection.close()
|
|
1393
|
+
|
|
1394
|
+
def create_publication(self, db: str, name: str, schematables: List[str]) -> None:
|
|
1395
|
+
"""Create PostgreSQL publication."""
|
|
1396
|
+
connection = None
|
|
1397
|
+
try:
|
|
1398
|
+
connection = self._connect_to_database(database=db)
|
|
1399
|
+
with connection, connection.cursor() as cursor:
|
|
1400
|
+
cursor.execute(
|
|
1401
|
+
SQL("CREATE PUBLICATION {} FOR TABLE {};").format(
|
|
1402
|
+
Identifier(name),
|
|
1403
|
+
SQL(",").join(
|
|
1404
|
+
Identifier(schematable.split(".")[0], schematable.split(".")[1])
|
|
1405
|
+
for schematable in schematables
|
|
1406
|
+
),
|
|
1407
|
+
)
|
|
1408
|
+
)
|
|
1409
|
+
except psycopg2.Error as e:
|
|
1410
|
+
logger.error(f"Failed to create Postgresql publication: {e}")
|
|
1411
|
+
raise PostgreSQLCreatePublicationError() from e
|
|
1412
|
+
finally:
|
|
1413
|
+
if connection:
|
|
1414
|
+
connection.close()
|
|
1415
|
+
|
|
1416
|
+
def publication_exists(self, db: str, publication: str) -> bool:
|
|
1417
|
+
"""Check whether specified subscription in database exists."""
|
|
1418
|
+
connection = None
|
|
1419
|
+
try:
|
|
1420
|
+
connection = self._connect_to_database(database=db)
|
|
1421
|
+
with connection, connection.cursor() as cursor:
|
|
1422
|
+
cursor.execute(
|
|
1423
|
+
SQL("SELECT pubname FROM pg_publication WHERE pubname={};").format(
|
|
1424
|
+
Literal(publication)
|
|
1425
|
+
)
|
|
1426
|
+
)
|
|
1427
|
+
return cursor.fetchone() is not None
|
|
1428
|
+
except psycopg2.Error as e:
|
|
1429
|
+
logger.error(f"Failed to check Postgresql publication existence: {e}")
|
|
1430
|
+
raise PostgreSQLPublicationExistsError() from e
|
|
1431
|
+
finally:
|
|
1432
|
+
if connection:
|
|
1433
|
+
connection.close()
|
|
1434
|
+
|
|
1435
|
+
def alter_publication(self, db: str, name: str, schematables: List[str]) -> None:
|
|
1436
|
+
"""Alter PostgreSQL publication."""
|
|
1437
|
+
connection = None
|
|
1438
|
+
try:
|
|
1439
|
+
connection = self._connect_to_database(database=db)
|
|
1440
|
+
with connection, connection.cursor() as cursor:
|
|
1441
|
+
cursor.execute(
|
|
1442
|
+
SQL("ALTER PUBLICATION {} SET TABLE {};").format(
|
|
1443
|
+
Identifier(name),
|
|
1444
|
+
SQL(",").join(
|
|
1445
|
+
Identifier(schematable.split(".")[0], schematable.split(".")[1])
|
|
1446
|
+
for schematable in schematables
|
|
1447
|
+
),
|
|
1448
|
+
)
|
|
1449
|
+
)
|
|
1450
|
+
except psycopg2.Error as e:
|
|
1451
|
+
logger.error(f"Failed to alter Postgresql publication: {e}")
|
|
1452
|
+
raise PostgreSQLAlterPublicationError() from e
|
|
1453
|
+
finally:
|
|
1454
|
+
if connection:
|
|
1455
|
+
connection.close()
|
|
1456
|
+
|
|
1457
|
+
def drop_publication(self, db: str, publication: str) -> None:
|
|
1458
|
+
"""Drop PostgreSQL publication."""
|
|
1459
|
+
connection = None
|
|
1460
|
+
try:
|
|
1461
|
+
connection = self._connect_to_database(database=db)
|
|
1462
|
+
with connection, connection.cursor() as cursor:
|
|
1463
|
+
cursor.execute(
|
|
1464
|
+
SQL("DROP PUBLICATION IF EXISTS {};").format(
|
|
1465
|
+
Identifier(publication),
|
|
1466
|
+
)
|
|
1467
|
+
)
|
|
1468
|
+
except psycopg2.Error as e:
|
|
1469
|
+
logger.error(f"Failed to drop Postgresql publication: {e}")
|
|
1470
|
+
raise PostgreSQLDropPublicationError() from e
|
|
1471
|
+
finally:
|
|
1472
|
+
if connection:
|
|
1473
|
+
connection.close()
|
|
1474
|
+
|
|
1475
|
+
def create_subscription(
|
|
1476
|
+
self,
|
|
1477
|
+
subscription: str,
|
|
1478
|
+
host: str,
|
|
1479
|
+
db: str,
|
|
1480
|
+
user: str,
|
|
1481
|
+
password: str,
|
|
1482
|
+
publication: str,
|
|
1483
|
+
replication_slot: str,
|
|
1484
|
+
) -> None:
|
|
1485
|
+
"""Create PostgreSQL subscription."""
|
|
1486
|
+
connection = None
|
|
1487
|
+
try:
|
|
1488
|
+
connection = self._connect_to_database(database=db)
|
|
1489
|
+
with connection, connection.cursor() as cursor:
|
|
1490
|
+
cursor.execute(
|
|
1491
|
+
SQL(
|
|
1492
|
+
"CREATE SUBSCRIPTION {} CONNECTION {} PUBLICATION {} WITH (copy_data=true,create_slot=false,enabled=true,slot_name={});"
|
|
1493
|
+
).format(
|
|
1494
|
+
Identifier(subscription),
|
|
1495
|
+
Literal(f"host={host} dbname={db} user={user} password={password}"),
|
|
1496
|
+
Identifier(publication),
|
|
1497
|
+
Identifier(replication_slot),
|
|
1498
|
+
)
|
|
1499
|
+
)
|
|
1500
|
+
except psycopg2.Error as e:
|
|
1501
|
+
logger.error(f"Failed to create Postgresql subscription: {e}")
|
|
1502
|
+
raise PostgreSQLCreateSubscriptionError() from e
|
|
1503
|
+
finally:
|
|
1504
|
+
if connection:
|
|
1505
|
+
connection.close()
|
|
1506
|
+
|
|
1507
|
+
def subscription_exists(self, db: str, subscription: str) -> bool:
|
|
1508
|
+
"""Check whether specified subscription in database exists."""
|
|
1509
|
+
connection = None
|
|
1510
|
+
try:
|
|
1511
|
+
connection = self._connect_to_database(database=db)
|
|
1512
|
+
with connection, connection.cursor() as cursor:
|
|
1513
|
+
cursor.execute(
|
|
1514
|
+
SQL("SELECT subname FROM pg_subscription WHERE subname={};").format(
|
|
1515
|
+
Literal(subscription)
|
|
1516
|
+
)
|
|
1517
|
+
)
|
|
1518
|
+
return cursor.fetchone() is not None
|
|
1519
|
+
except psycopg2.Error as e:
|
|
1520
|
+
logger.error(f"Failed to check Postgresql subscription existence: {e}")
|
|
1521
|
+
raise PostgreSQLSubscriptionExistsError() from e
|
|
1522
|
+
finally:
|
|
1523
|
+
if connection:
|
|
1524
|
+
connection.close()
|
|
1525
|
+
|
|
1526
|
+
def update_subscription(self, db: str, subscription: str, host: str, user: str, password: str):
|
|
1527
|
+
"""Update PostgreSQL subscription connection details."""
|
|
1528
|
+
connection = None
|
|
1529
|
+
try:
|
|
1530
|
+
connection = self._connect_to_database(database=db)
|
|
1531
|
+
with connection, connection.cursor() as cursor:
|
|
1532
|
+
cursor.execute(
|
|
1533
|
+
SQL("ALTER SUBSCRIPTION {} CONNECTION {}").format(
|
|
1534
|
+
Identifier(subscription),
|
|
1535
|
+
Literal(f"host={host} dbname={db} user={user} password={password}"),
|
|
1536
|
+
)
|
|
1537
|
+
)
|
|
1538
|
+
except psycopg2.Error as e:
|
|
1539
|
+
logger.error(f"Failed to update Postgresql subscription: {e}")
|
|
1540
|
+
raise PostgreSQLUpdateSubscriptionError() from e
|
|
1541
|
+
finally:
|
|
1542
|
+
if connection:
|
|
1543
|
+
connection.close()
|
|
1544
|
+
|
|
1545
|
+
def refresh_subscription(self, db: str, subscription: str):
|
|
1546
|
+
"""Refresh PostgreSQL subscription to pull publication changes."""
|
|
1547
|
+
connection = None
|
|
1548
|
+
try:
|
|
1549
|
+
connection = self._connect_to_database(database=db)
|
|
1550
|
+
with connection.cursor() as cursor:
|
|
1551
|
+
cursor.execute(
|
|
1552
|
+
SQL("ALTER SUBSCRIPTION {} REFRESH PUBLICATION").format(
|
|
1553
|
+
Identifier(subscription)
|
|
1554
|
+
)
|
|
1555
|
+
)
|
|
1556
|
+
except psycopg2.Error as e:
|
|
1557
|
+
logger.error(f"Failed to refresh Postgresql subscription: {e}")
|
|
1558
|
+
raise PostgreSQLRefreshSubscriptionError() from e
|
|
1559
|
+
finally:
|
|
1560
|
+
if connection:
|
|
1561
|
+
connection.close()
|
|
1562
|
+
|
|
1563
|
+
def drop_subscription(self, db: str, subscription: str) -> None:
|
|
1564
|
+
"""Drop PostgreSQL subscription."""
|
|
1565
|
+
connection = None
|
|
1566
|
+
try:
|
|
1567
|
+
connection = self._connect_to_database(database=db)
|
|
1568
|
+
with connection, connection.cursor() as cursor:
|
|
1569
|
+
cursor.execute(
|
|
1570
|
+
SQL("ALTER SUBSCRIPTION {} DISABLE;").format(
|
|
1571
|
+
Identifier(subscription),
|
|
1572
|
+
)
|
|
1573
|
+
)
|
|
1574
|
+
cursor.execute(
|
|
1575
|
+
SQL("ALTER SUBSCRIPTION {} SET (slot_name=NONE);").format(
|
|
1576
|
+
Identifier(subscription),
|
|
1577
|
+
)
|
|
1578
|
+
)
|
|
1579
|
+
cursor.execute(
|
|
1580
|
+
SQL("DROP SUBSCRIPTION {};").format(
|
|
1581
|
+
Identifier(subscription),
|
|
1582
|
+
)
|
|
1583
|
+
)
|
|
1584
|
+
except psycopg2.Error as e:
|
|
1585
|
+
logger.error(f"Failed to drop Postgresql subscription: {e}")
|
|
1586
|
+
raise PostgreSQLDropSubscriptionError() from e
|
|
1587
|
+
finally:
|
|
1588
|
+
if connection:
|
|
1589
|
+
connection.close()
|
|
1590
|
+
|
|
1591
|
+
@staticmethod
|
|
1592
|
+
def build_postgresql_group_map(group_map: Optional[str]) -> List[Tuple]:
|
|
1593
|
+
"""Build the PostgreSQL authorization group-map.
|
|
1594
|
+
|
|
1595
|
+
Args:
|
|
1596
|
+
group_map: serialized group-map with the following format:
|
|
1597
|
+
<ldap_group_1>=<psql_group_1>,
|
|
1598
|
+
<ldap_group_2>=<psql_group_2>,
|
|
1599
|
+
...
|
|
1600
|
+
|
|
1601
|
+
Returns:
|
|
1602
|
+
List of LDAP group to PostgreSQL group tuples.
|
|
1603
|
+
"""
|
|
1604
|
+
if group_map is None:
|
|
1605
|
+
return []
|
|
1606
|
+
|
|
1607
|
+
group_mappings = group_map.split(",")
|
|
1608
|
+
group_mappings = (mapping.strip() for mapping in group_mappings)
|
|
1609
|
+
group_map_list = []
|
|
1610
|
+
|
|
1611
|
+
for mapping in group_mappings:
|
|
1612
|
+
mapping_parts = mapping.split("=")
|
|
1613
|
+
if len(mapping_parts) != 2:
|
|
1614
|
+
raise ValueError("The group-map must contain value pairs split by commas")
|
|
1615
|
+
|
|
1616
|
+
ldap_group = mapping_parts[0]
|
|
1617
|
+
psql_group = mapping_parts[1]
|
|
1618
|
+
|
|
1619
|
+
if psql_group in ACCESS_GROUPS:
|
|
1620
|
+
logger.warning(f"Tried to assign LDAP users to forbidden group: {psql_group}")
|
|
1621
|
+
continue
|
|
1622
|
+
|
|
1623
|
+
group_map_list.append((ldap_group, psql_group))
|
|
1624
|
+
|
|
1625
|
+
return group_map_list
|
|
1626
|
+
|
|
1627
|
+
@staticmethod
|
|
1628
|
+
def build_postgresql_parameters(
|
|
1629
|
+
config_options: ConfigData, available_memory: int, limit_memory: Optional[int] = None
|
|
1630
|
+
) -> Optional[dict]:
|
|
1631
|
+
"""Builds the PostgreSQL parameters.
|
|
1632
|
+
|
|
1633
|
+
Args:
|
|
1634
|
+
config_options: charm config options containing profile and PostgreSQL parameters.
|
|
1635
|
+
available_memory: available memory to use in calculation in bytes.
|
|
1636
|
+
limit_memory: (optional) limit memory to use in calculation in bytes.
|
|
1637
|
+
|
|
1638
|
+
Returns:
|
|
1639
|
+
Dictionary with the PostgreSQL parameters.
|
|
1640
|
+
"""
|
|
1641
|
+
if limit_memory:
|
|
1642
|
+
available_memory = min(available_memory, limit_memory)
|
|
1643
|
+
profile = config_options["profile"]
|
|
1644
|
+
logger.debug(f"Building PostgreSQL parameters for {profile=} and {available_memory=}")
|
|
1645
|
+
parameters = {}
|
|
1646
|
+
for config, value in config_options.items():
|
|
1647
|
+
# Filter config option not related to PostgreSQL parameters.
|
|
1648
|
+
if not config.startswith((
|
|
1649
|
+
"connection",
|
|
1650
|
+
"cpu",
|
|
1651
|
+
"durability",
|
|
1652
|
+
"instance",
|
|
1653
|
+
"logging",
|
|
1654
|
+
"memory",
|
|
1655
|
+
"optimizer",
|
|
1656
|
+
"request",
|
|
1657
|
+
"response",
|
|
1658
|
+
"session",
|
|
1659
|
+
"storage",
|
|
1660
|
+
"vacuum",
|
|
1661
|
+
)):
|
|
1662
|
+
continue
|
|
1663
|
+
parameter = "_".join(config.split("_")[1:])
|
|
1664
|
+
if parameter in ["date_style", "time_zone"]:
|
|
1665
|
+
parameter = "".join(x.capitalize() for x in parameter.split("_"))
|
|
1666
|
+
parameters[parameter] = value
|
|
1667
|
+
shared_buffers_max_value_in_mb = int(available_memory * 0.4 / 10**6)
|
|
1668
|
+
shared_buffers_max_value = int(shared_buffers_max_value_in_mb * 10**3 / 8)
|
|
1669
|
+
if parameters.get("shared_buffers", 0) > shared_buffers_max_value:
|
|
1670
|
+
raise Exception(
|
|
1671
|
+
f"Shared buffers config option should be at most 40% of the available memory, which is {shared_buffers_max_value_in_mb}MB"
|
|
1672
|
+
)
|
|
1673
|
+
if profile == "production":
|
|
1674
|
+
if "shared_buffers" in parameters:
|
|
1675
|
+
# Convert to bytes to use in the calculation.
|
|
1676
|
+
shared_buffers = parameters["shared_buffers"] * 8 * 10**3
|
|
1677
|
+
else:
|
|
1678
|
+
# Use 25% of the available memory for shared_buffers.
|
|
1679
|
+
# and the remaining as cache memory.
|
|
1680
|
+
shared_buffers = int(available_memory * 0.25)
|
|
1681
|
+
parameters["shared_buffers"] = f"{int(shared_buffers * 128 / 10**6)}"
|
|
1682
|
+
effective_cache_size = int(available_memory - shared_buffers)
|
|
1683
|
+
parameters.update({
|
|
1684
|
+
"effective_cache_size": f"{int(effective_cache_size / 10**6) * 128}"
|
|
1685
|
+
})
|
|
1686
|
+
return parameters
|
|
1687
|
+
|
|
1688
|
+
def validate_date_style(self, date_style: str) -> bool:
|
|
1689
|
+
"""Validate a date style against PostgreSQL.
|
|
1690
|
+
|
|
1691
|
+
Returns:
|
|
1692
|
+
Whether the date style is valid.
|
|
1693
|
+
"""
|
|
1694
|
+
try:
|
|
1695
|
+
with self._connect_to_database(
|
|
1696
|
+
database_host=self.current_host
|
|
1697
|
+
) as connection, connection.cursor() as cursor:
|
|
1698
|
+
cursor.execute(
|
|
1699
|
+
SQL(
|
|
1700
|
+
"SET DateStyle to {};",
|
|
1701
|
+
).format(Identifier(date_style))
|
|
1702
|
+
)
|
|
1703
|
+
return True
|
|
1704
|
+
except psycopg2.Error:
|
|
1705
|
+
return False
|
|
1706
|
+
|
|
1707
|
+
def validate_group_map(self, group_map: Optional[str]) -> bool:
|
|
1708
|
+
"""Validate the PostgreSQL authorization group-map.
|
|
1709
|
+
|
|
1710
|
+
Args:
|
|
1711
|
+
group_map: serialized group-map with the following format:
|
|
1712
|
+
<ldap_group_1>=<psql_group_1>,
|
|
1713
|
+
<ldap_group_2>=<psql_group_2>,
|
|
1714
|
+
...
|
|
1715
|
+
|
|
1716
|
+
Returns:
|
|
1717
|
+
Whether the group-map is valid.
|
|
1718
|
+
"""
|
|
1719
|
+
if group_map is None:
|
|
1720
|
+
return True
|
|
1721
|
+
|
|
1722
|
+
try:
|
|
1723
|
+
parsed_group_map = self.build_postgresql_group_map(group_map)
|
|
1724
|
+
except ValueError:
|
|
1725
|
+
return False
|
|
1726
|
+
|
|
1727
|
+
for _, psql_group in parsed_group_map:
|
|
1728
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
1729
|
+
query = SQL("SELECT TRUE FROM pg_roles WHERE rolname={};")
|
|
1730
|
+
query = query.format(Literal(psql_group))
|
|
1731
|
+
cursor.execute(query)
|
|
1732
|
+
|
|
1733
|
+
if cursor.fetchone() is None:
|
|
1734
|
+
return False
|
|
1735
|
+
|
|
1736
|
+
return True
|
|
1737
|
+
|
|
1738
|
+
def is_user_in_hba(self, username: str) -> bool:
|
|
1739
|
+
"""Check if user was added in pg_hba."""
|
|
1740
|
+
connection = None
|
|
1741
|
+
try:
|
|
1742
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
1743
|
+
cursor.execute(
|
|
1744
|
+
SQL(
|
|
1745
|
+
"SELECT COUNT(*) FROM pg_hba_file_rules WHERE {} = ANY(user_name);"
|
|
1746
|
+
).format(Literal(username))
|
|
1747
|
+
)
|
|
1748
|
+
if result := cursor.fetchone():
|
|
1749
|
+
return result[0] > 0
|
|
1750
|
+
return False
|
|
1751
|
+
except psycopg2.Error as e:
|
|
1752
|
+
logger.debug(f"Failed to check pg_hba: {e}")
|
|
1753
|
+
return False
|
|
1754
|
+
finally:
|
|
1755
|
+
if connection:
|
|
1756
|
+
connection.close()
|
|
1757
|
+
|
|
1758
|
+
def drop_hba_triggers(self) -> None:
|
|
1759
|
+
"""Drop pg_hba triggers on schema change."""
|
|
1760
|
+
try:
|
|
1761
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
1762
|
+
cursor.execute(
|
|
1763
|
+
SQL(
|
|
1764
|
+
"SELECT datname FROM pg_database WHERE datname <> 'template0' AND datname <>'postgres';"
|
|
1765
|
+
)
|
|
1766
|
+
)
|
|
1767
|
+
databases = [row[0] for row in cursor.fetchall()]
|
|
1768
|
+
except psycopg2.Error as e:
|
|
1769
|
+
logger.warning(f"Failed to get databases when removing hba trigger: {e}")
|
|
1770
|
+
return
|
|
1771
|
+
finally:
|
|
1772
|
+
if connection:
|
|
1773
|
+
connection.close()
|
|
1774
|
+
|
|
1775
|
+
# Existing objects need to be reassigned in each database
|
|
1776
|
+
# before the user can be deleted.
|
|
1777
|
+
|
|
1778
|
+
for database in databases:
|
|
1779
|
+
try:
|
|
1780
|
+
with self._connect_to_database(
|
|
1781
|
+
database
|
|
1782
|
+
) as connection, connection.cursor() as cursor:
|
|
1783
|
+
cursor.execute(
|
|
1784
|
+
SQL("DROP EVENT TRIGGER IF EXISTS update_pg_hba_on_create_schema;")
|
|
1785
|
+
)
|
|
1786
|
+
cursor.execute(
|
|
1787
|
+
SQL("DROP EVENT TRIGGER IF EXISTS update_pg_hba_on_drop_schema;")
|
|
1788
|
+
)
|
|
1789
|
+
except psycopg2.Error as e:
|
|
1790
|
+
logger.warning(f"Failed to remove hba trigger for {database}: {e}")
|
|
1791
|
+
finally:
|
|
1792
|
+
if connection:
|
|
1793
|
+
connection.close()
|