postgresql-charms-single-kernel 16.1.11__tar.gz → 16.2.1__tar.gz
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-16.1.11 → postgresql_charms_single_kernel-16.2.1}/PKG-INFO +3 -1
- {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/pyproject.toml +10 -6
- postgresql_charms_single_kernel-16.2.1/single_kernel_postgresql/compat/postgresql.py +583 -0
- postgresql_charms_single_kernel-16.2.1/single_kernel_postgresql/compat/pyproject.toml +58 -0
- {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/config/literals.py +2 -0
- postgresql_charms_single_kernel-16.2.1/single_kernel_postgresql/config/pyproject.toml +58 -0
- postgresql_charms_single_kernel-16.2.1/single_kernel_postgresql/utils/__init__.py +197 -0
- {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/utils/postgresql.py +128 -566
- {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/LICENSE +0 -0
- {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/README.md +0 -0
- {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/abstract_charm.py +0 -0
- {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/charmcraft.yaml +0 -0
- {postgresql_charms_single_kernel-16.1.11/single_kernel_postgresql/events → postgresql_charms_single_kernel-16.2.1/single_kernel_postgresql/compat}/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/config/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.1.11/single_kernel_postgresql/utils → postgresql_charms_single_kernel-16.2.1/single_kernel_postgresql/events}/__init__.py +0 -0
- {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/events/tls_transfer.py +0 -0
- {postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/single_kernel_postgresql/utils/filesystem.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: postgresql-charms-single-kernel
|
|
3
|
-
Version: 16.1
|
|
3
|
+
Version: 16.2.1
|
|
4
4
|
Summary: Shared and reusable code for PostgreSQL-related charms
|
|
5
5
|
Author: Canonical Data Platform
|
|
6
6
|
Author-email: Canonical Data Platform <data-platform@lists.launchpad.net>
|
|
@@ -212,7 +212,9 @@ Classifier: Operating System :: POSIX :: Linux
|
|
|
212
212
|
Requires-Dist: ops>=2.0.0
|
|
213
213
|
Requires-Dist: psycopg2>=2.9.10
|
|
214
214
|
Requires-Dist: tenacity>=9.0.0
|
|
215
|
+
Requires-Dist: httpx ; python_full_version >= '3.12' and extra == 'postgresql'
|
|
215
216
|
Requires-Python: >=3.8, <4.0
|
|
217
|
+
Provides-Extra: postgresql
|
|
216
218
|
Description-Content-Type: text/markdown
|
|
217
219
|
|
|
218
220
|
# postgresql-single-kernel-library
|
{postgresql_charms_single_kernel-16.1.11 → postgresql_charms_single_kernel-16.2.1}/pyproject.toml
RENAMED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[project]
|
|
5
5
|
name = "postgresql-charms-single-kernel"
|
|
6
6
|
description = "Shared and reusable code for PostgreSQL-related charms"
|
|
7
|
-
version = "16.1
|
|
7
|
+
version = "16.2.1"
|
|
8
8
|
readme = "README.md"
|
|
9
9
|
license = {file = "LICENSE"}
|
|
10
10
|
authors = [
|
|
@@ -22,6 +22,9 @@ dependencies = [
|
|
|
22
22
|
"tenacity>=9.0.0",
|
|
23
23
|
]
|
|
24
24
|
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
postgresql = ["httpx; python_version >= '3.12'"]
|
|
27
|
+
|
|
25
28
|
[build-system]
|
|
26
29
|
requires = ["uv_build>=0.11.0,<0.12.0"]
|
|
27
30
|
build-backend = "uv_build"
|
|
@@ -32,15 +35,15 @@ module-root = ""
|
|
|
32
35
|
|
|
33
36
|
[dependency-groups]
|
|
34
37
|
format = [
|
|
35
|
-
"ruff==0.15.
|
|
38
|
+
"ruff==0.15.13"
|
|
36
39
|
]
|
|
37
40
|
lint = [
|
|
38
41
|
"codespell==2.4.2",
|
|
39
|
-
"ty==0.0.
|
|
42
|
+
"ty==0.0.37"
|
|
40
43
|
]
|
|
41
44
|
unit = [
|
|
42
|
-
"coverage[toml]==7.
|
|
43
|
-
"pytest==9.0.
|
|
45
|
+
"coverage[toml]==7.14.0",
|
|
46
|
+
"pytest==9.0.3"
|
|
44
47
|
]
|
|
45
48
|
|
|
46
49
|
|
|
@@ -64,7 +67,7 @@ log_cli_level = "INFO"
|
|
|
64
67
|
[tool.ruff]
|
|
65
68
|
# preview and explicit preview are enabled for CPY001
|
|
66
69
|
preview = true
|
|
67
|
-
target-version = "
|
|
70
|
+
target-version = "py312"
|
|
68
71
|
src = ["src", "."]
|
|
69
72
|
line-length = 99
|
|
70
73
|
|
|
@@ -111,6 +114,7 @@ convention = "google"
|
|
|
111
114
|
|
|
112
115
|
[tool.ty.environment]
|
|
113
116
|
python = ".tox/lint/"
|
|
117
|
+
python-version = "3.12"
|
|
114
118
|
|
|
115
119
|
[tool.ty.src]
|
|
116
120
|
exclude = ["tests"]
|
|
@@ -0,0 +1,583 @@
|
|
|
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
|
+
import psycopg2.errors
|
|
28
|
+
import psycopg2.extensions
|
|
29
|
+
from psycopg2.sql import SQL, Identifier, Literal
|
|
30
|
+
|
|
31
|
+
from ..config.literals import (
|
|
32
|
+
BACKUP_USER,
|
|
33
|
+
SYSTEM_USERS,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
ACCESS_GROUP_RELATION = "relation_access"
|
|
37
|
+
|
|
38
|
+
ROLE_STATS = "charmed_stats"
|
|
39
|
+
ROLE_READ = "charmed_read"
|
|
40
|
+
ROLE_DML = "charmed_dml"
|
|
41
|
+
ROLE_BACKUP = "charmed_backup"
|
|
42
|
+
ROLE_DBA = "charmed_dba"
|
|
43
|
+
ROLE_ADMIN = "charmed_admin"
|
|
44
|
+
ROLE_DATABASES_OWNER = "charmed_databases_owner"
|
|
45
|
+
ALLOWED_ROLES = {
|
|
46
|
+
ROLE_STATS,
|
|
47
|
+
ROLE_READ,
|
|
48
|
+
ROLE_DML,
|
|
49
|
+
ROLE_ADMIN,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
INVALID_DATABASE_NAME_BLOCKING_MESSAGE = "invalid database name"
|
|
53
|
+
INVALID_DATABASE_NAMES = ["databases", "postgres", "template0", "template1"]
|
|
54
|
+
INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE = "invalid role(s) for extra user roles"
|
|
55
|
+
|
|
56
|
+
REQUIRED_PLUGINS = {
|
|
57
|
+
"address_standardizer": ["postgis"],
|
|
58
|
+
"address_standardizer_data_us": ["postgis"],
|
|
59
|
+
"jsonb_plperl": ["plperl"],
|
|
60
|
+
"postgis_raster": ["postgis"],
|
|
61
|
+
"postgis_tiger_geocoder": ["postgis", "fuzzystrmatch"],
|
|
62
|
+
"postgis_topology": ["postgis"],
|
|
63
|
+
}
|
|
64
|
+
DEPENDENCY_PLUGINS = set()
|
|
65
|
+
for dependencies in REQUIRED_PLUGINS.values():
|
|
66
|
+
DEPENDENCY_PLUGINS |= set(dependencies)
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PostgreSQLBaseError(Exception):
|
|
72
|
+
"""Base lib exception."""
|
|
73
|
+
|
|
74
|
+
message = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PostgreSQLCreateDatabaseError(PostgreSQLBaseError):
|
|
78
|
+
"""Exception raised when creating a database fails."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, message: Optional[str] = None):
|
|
81
|
+
super().__init__(message)
|
|
82
|
+
self.message = message
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PostgreSQLCreateUserError(PostgreSQLBaseError):
|
|
86
|
+
"""Exception raised when creating a user fails."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, message: Optional[str] = None):
|
|
89
|
+
super().__init__(message)
|
|
90
|
+
self.message = message
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class PostgreSQLUndefinedHostError(PostgreSQLBaseError):
|
|
94
|
+
"""Exception when host is not set."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class PostgreSQLUndefinedPasswordError(PostgreSQLBaseError):
|
|
98
|
+
"""Exception when password is not set."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PostgreSQLDeleteUserError(PostgreSQLBaseError):
|
|
102
|
+
"""Exception raised when deleting a user fails."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class PostgreSQLListUsersError(PostgreSQLBaseError):
|
|
106
|
+
"""Exception raised when retrieving PostgreSQL users list fails."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PostgreSQLGetPostgreSQLVersionError(PostgreSQLBaseError):
|
|
110
|
+
"""Exception raised when retrieving PostgreSQL version fails."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class PostgreSQLEnableDisableExtensionError(PostgreSQLBaseError):
|
|
114
|
+
"""Exception raised when enabling/disabling an extension fails."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class PostgreSQLBase:
|
|
118
|
+
"""Class to encapsulate all operations related to interacting with PostgreSQL instance."""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
primary_host: Optional[str],
|
|
123
|
+
current_host: Optional[str],
|
|
124
|
+
user: str,
|
|
125
|
+
password: Optional[str],
|
|
126
|
+
database: str,
|
|
127
|
+
):
|
|
128
|
+
"""Create a PostgreSQL helper.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
primary_host: hostname or address for primary database host.
|
|
132
|
+
current_host: hostname or address for the current database host.
|
|
133
|
+
user: username to connect as.
|
|
134
|
+
password: password for the user.
|
|
135
|
+
database: default database name.
|
|
136
|
+
"""
|
|
137
|
+
self.primary_host = primary_host
|
|
138
|
+
self.current_host = current_host
|
|
139
|
+
self.user = user
|
|
140
|
+
self.password = password
|
|
141
|
+
self.database = database
|
|
142
|
+
|
|
143
|
+
def _configure_pgaudit(self, enable: bool) -> None:
|
|
144
|
+
connection = None
|
|
145
|
+
try:
|
|
146
|
+
connection = self._connect_to_database()
|
|
147
|
+
connection.autocommit = True
|
|
148
|
+
with connection.cursor() as cursor:
|
|
149
|
+
cursor.execute("RESET ROLE;")
|
|
150
|
+
if enable:
|
|
151
|
+
cursor.execute("ALTER SYSTEM SET pgaudit.log = 'ROLE,DDL,MISC,MISC_SET';")
|
|
152
|
+
cursor.execute("ALTER SYSTEM SET pgaudit.log_client TO off;")
|
|
153
|
+
cursor.execute("ALTER SYSTEM SET pgaudit.log_parameter TO off;")
|
|
154
|
+
else:
|
|
155
|
+
cursor.execute("ALTER SYSTEM RESET pgaudit.log;")
|
|
156
|
+
cursor.execute("ALTER SYSTEM RESET pgaudit.log_client;")
|
|
157
|
+
cursor.execute("ALTER SYSTEM RESET pgaudit.log_parameter;")
|
|
158
|
+
cursor.execute("SELECT pg_reload_conf();")
|
|
159
|
+
finally:
|
|
160
|
+
if connection is not None:
|
|
161
|
+
connection.close()
|
|
162
|
+
|
|
163
|
+
def _connect_to_database(
|
|
164
|
+
self, database: Optional[str] = None, database_host: Optional[str] = None
|
|
165
|
+
) -> psycopg2.extensions.connection:
|
|
166
|
+
"""Creates a connection to the database.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
database: database to connect to (defaults to the database
|
|
170
|
+
provided when the object for this class was created).
|
|
171
|
+
database_host: host to connect to instead of the primary host.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
psycopg2 connection object.
|
|
175
|
+
"""
|
|
176
|
+
host = database_host if database_host is not None else self.primary_host
|
|
177
|
+
if not host:
|
|
178
|
+
raise PostgreSQLUndefinedHostError("Host not set")
|
|
179
|
+
if not self.password:
|
|
180
|
+
raise PostgreSQLUndefinedPasswordError("Password not set")
|
|
181
|
+
|
|
182
|
+
dbname = database if database else self.database
|
|
183
|
+
logger.debug(
|
|
184
|
+
f"New DB connection: dbname='{dbname}' user='{self.user}' host='{host}' connect_timeout=1"
|
|
185
|
+
)
|
|
186
|
+
connection = psycopg2.connect(
|
|
187
|
+
f"dbname='{dbname}' user='{self.user}' host='{host}'"
|
|
188
|
+
f"password='{self.password}' connect_timeout=1"
|
|
189
|
+
)
|
|
190
|
+
connection.autocommit = True
|
|
191
|
+
return connection
|
|
192
|
+
|
|
193
|
+
def create_database(
|
|
194
|
+
self,
|
|
195
|
+
database: str,
|
|
196
|
+
plugins: Optional[List[str]] = None,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Creates a new database and grant privileges to a user on it.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
database: database to be created.
|
|
202
|
+
plugins: extensions to enable in the new database.
|
|
203
|
+
"""
|
|
204
|
+
# The limit of 49 characters for the database name is due to the usernames that
|
|
205
|
+
# are created for each database, which have the prefix `charmed_` and a suffix
|
|
206
|
+
# like `_owner`, which summed to the database name must not exceed PostgreSQL
|
|
207
|
+
# maximum identifier length (63 characters, which is, the prefix, 8 characters,
|
|
208
|
+
# + database name, 49 characters maximum, + suffix, 6 characters).
|
|
209
|
+
if len(database) > 49:
|
|
210
|
+
logger.error(f"Invalid database name (it must not exceed 49 characters): {database}.")
|
|
211
|
+
raise PostgreSQLCreateDatabaseError(INVALID_DATABASE_NAME_BLOCKING_MESSAGE)
|
|
212
|
+
if database in INVALID_DATABASE_NAMES:
|
|
213
|
+
logger.error(f"Invalid database name: {database}.")
|
|
214
|
+
raise PostgreSQLCreateDatabaseError(INVALID_DATABASE_NAME_BLOCKING_MESSAGE)
|
|
215
|
+
plugins = plugins if plugins else []
|
|
216
|
+
try:
|
|
217
|
+
connection = self._connect_to_database()
|
|
218
|
+
cursor = connection.cursor()
|
|
219
|
+
cursor.execute(
|
|
220
|
+
SQL("SELECT datname FROM pg_database WHERE datname={};").format(Literal(database))
|
|
221
|
+
)
|
|
222
|
+
if cursor.fetchone() is None:
|
|
223
|
+
cursor.execute(SQL("SET ROLE {};").format(Identifier(ROLE_DATABASES_OWNER)))
|
|
224
|
+
cursor.execute(SQL("CREATE DATABASE {};").format(Identifier(database)))
|
|
225
|
+
cursor.execute(
|
|
226
|
+
SQL("REVOKE ALL PRIVILEGES ON DATABASE {} FROM PUBLIC;").format(
|
|
227
|
+
Identifier(database)
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
with self._connect_to_database(database=database) as conn, conn.cursor() as curs:
|
|
231
|
+
curs.execute(SQL("SET ROLE {};").format(Identifier(ROLE_DATABASES_OWNER)))
|
|
232
|
+
curs.execute(SQL("SELECT set_up_predefined_catalog_roles();"))
|
|
233
|
+
except psycopg2.Error as e:
|
|
234
|
+
logger.error(f"Failed to create database: {e}")
|
|
235
|
+
raise PostgreSQLCreateDatabaseError() from e
|
|
236
|
+
|
|
237
|
+
# Enable preset extensions
|
|
238
|
+
if plugins:
|
|
239
|
+
self.enable_disable_extensions(dict.fromkeys(plugins, True), database)
|
|
240
|
+
|
|
241
|
+
def create_user(
|
|
242
|
+
self,
|
|
243
|
+
user: str,
|
|
244
|
+
password: Optional[str] = None,
|
|
245
|
+
admin: bool = False,
|
|
246
|
+
replication: bool = False,
|
|
247
|
+
extra_user_roles: Optional[List[str]] = None,
|
|
248
|
+
database: Optional[str] = None,
|
|
249
|
+
can_create_database: bool = False,
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Creates a database user.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
user: user to be created.
|
|
255
|
+
password: password to be assigned to the user.
|
|
256
|
+
admin: whether the user should have additional admin privileges.
|
|
257
|
+
replication: whether the user should have replication privileges.
|
|
258
|
+
extra_user_roles: additional privileges and/or roles to be assigned to the user.
|
|
259
|
+
database: optional database to allow the user to connect to.
|
|
260
|
+
can_create_database: whether the user should be able to create databases.
|
|
261
|
+
"""
|
|
262
|
+
try:
|
|
263
|
+
roles, privileges = self._process_extra_user_roles(user, extra_user_roles)
|
|
264
|
+
|
|
265
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
266
|
+
# Create or update the user.
|
|
267
|
+
cursor.execute(
|
|
268
|
+
SQL("SELECT TRUE FROM pg_roles WHERE rolname={};").format(Literal(user))
|
|
269
|
+
)
|
|
270
|
+
if cursor.fetchone() is not None:
|
|
271
|
+
user_definition = "ALTER ROLE {} "
|
|
272
|
+
altering = True
|
|
273
|
+
else:
|
|
274
|
+
user_definition = "CREATE ROLE {} "
|
|
275
|
+
altering = False
|
|
276
|
+
user_definition += f"WITH LOGIN{' SUPERUSER' if admin else ''}{' REPLICATION' if replication else ''} ENCRYPTED PASSWORD '{password}'"
|
|
277
|
+
if not altering:
|
|
278
|
+
user_definition, connect_statements = self._adjust_user_definition(
|
|
279
|
+
user, roles, database, user_definition
|
|
280
|
+
)
|
|
281
|
+
if can_create_database:
|
|
282
|
+
user_definition += " CREATEDB"
|
|
283
|
+
if privileges:
|
|
284
|
+
user_definition += f" {' '.join(privileges)}"
|
|
285
|
+
else:
|
|
286
|
+
db_roles, connect_statements = self._adjust_user_roles(user, roles, database)
|
|
287
|
+
roles = [*db_roles, *roles]
|
|
288
|
+
cursor.execute(SQL("RESET ROLE;"))
|
|
289
|
+
cursor.execute(SQL("BEGIN;"))
|
|
290
|
+
cursor.execute(SQL("SET LOCAL log_statement = 'none';"))
|
|
291
|
+
cursor.execute(SQL(f"{user_definition};").format(Identifier(user)))
|
|
292
|
+
cursor.execute(SQL("COMMIT;"))
|
|
293
|
+
if len(connect_statements) > 0:
|
|
294
|
+
for connect_statement in connect_statements:
|
|
295
|
+
cursor.execute(connect_statement)
|
|
296
|
+
|
|
297
|
+
# Add extra user roles to the new user.
|
|
298
|
+
for role in roles:
|
|
299
|
+
cursor.execute(
|
|
300
|
+
SQL("GRANT {} TO {};").format(Identifier(role), Identifier(user))
|
|
301
|
+
)
|
|
302
|
+
except psycopg2.Error as e:
|
|
303
|
+
logger.error(f"Failed to create user: {e}")
|
|
304
|
+
raise PostgreSQLCreateUserError() from e
|
|
305
|
+
|
|
306
|
+
def _adjust_user_definition(
|
|
307
|
+
self, user: str, roles: Optional[List[str]], database: Optional[str], user_definition: str
|
|
308
|
+
) -> Tuple[str, List[str]]:
|
|
309
|
+
"""Adjusts the user definition to include additional statements.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
A tuple containing the adjusted user definition and a list of additional statements.
|
|
313
|
+
"""
|
|
314
|
+
db_roles, connect_statements = self._adjust_user_roles(user, roles, database)
|
|
315
|
+
if db_roles:
|
|
316
|
+
str_roles = [f'"{role}"' for role in db_roles]
|
|
317
|
+
user_definition += f" IN ROLE {', '.join(str_roles)}"
|
|
318
|
+
return user_definition, connect_statements
|
|
319
|
+
|
|
320
|
+
def _adjust_user_roles(
|
|
321
|
+
self, user: str, roles: Optional[List[str]], database: Optional[str]
|
|
322
|
+
) -> Tuple[List[str], List[str]]:
|
|
323
|
+
"""Adjusts the user definition to include additional statements.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
A tuple containing the adjusted user definition and a list of additional statements.
|
|
327
|
+
"""
|
|
328
|
+
db_roles = []
|
|
329
|
+
connect_statements = []
|
|
330
|
+
if database:
|
|
331
|
+
if roles is not None and not any(
|
|
332
|
+
role in [ROLE_STATS, ROLE_READ, ROLE_DML, ROLE_BACKUP, ROLE_DBA] for role in roles
|
|
333
|
+
):
|
|
334
|
+
db_roles.append(f"charmed_{database}_admin")
|
|
335
|
+
db_roles.append(f"charmed_{database}_dml")
|
|
336
|
+
else:
|
|
337
|
+
connect_statements.append(
|
|
338
|
+
SQL("GRANT CONNECT ON DATABASE {} TO {};").format(
|
|
339
|
+
Identifier(database), Identifier(user)
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
if roles is not None and any(
|
|
343
|
+
role
|
|
344
|
+
in [
|
|
345
|
+
ROLE_STATS,
|
|
346
|
+
ROLE_READ,
|
|
347
|
+
ROLE_DML,
|
|
348
|
+
ROLE_BACKUP,
|
|
349
|
+
ROLE_DBA,
|
|
350
|
+
ROLE_ADMIN,
|
|
351
|
+
ROLE_DATABASES_OWNER,
|
|
352
|
+
]
|
|
353
|
+
for role in roles
|
|
354
|
+
):
|
|
355
|
+
for system_database in ["postgres", "template1"]:
|
|
356
|
+
connect_statements.append(
|
|
357
|
+
SQL("GRANT CONNECT ON DATABASE {} TO {};").format(
|
|
358
|
+
Identifier(system_database), Identifier(user)
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
return db_roles, connect_statements
|
|
362
|
+
|
|
363
|
+
def _process_extra_user_roles(
|
|
364
|
+
self, user: str, extra_user_roles: Optional[List[str]] = None
|
|
365
|
+
) -> Tuple[List[str], Set[str]]:
|
|
366
|
+
# Separate roles and privileges from the provided extra user roles.
|
|
367
|
+
roles = []
|
|
368
|
+
privileges = set()
|
|
369
|
+
if extra_user_roles:
|
|
370
|
+
if len(extra_user_roles) > 2 and sorted(extra_user_roles) != [
|
|
371
|
+
ROLE_ADMIN,
|
|
372
|
+
"createdb",
|
|
373
|
+
ACCESS_GROUP_RELATION,
|
|
374
|
+
]:
|
|
375
|
+
extra_user_roles.remove(ACCESS_GROUP_RELATION)
|
|
376
|
+
logger.error(
|
|
377
|
+
"Invalid extra user roles: "
|
|
378
|
+
f"{', '.join(extra_user_roles)}. "
|
|
379
|
+
f"Only 'createdb' and '{ROLE_ADMIN}' are allowed together."
|
|
380
|
+
)
|
|
381
|
+
raise PostgreSQLCreateUserError(INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE)
|
|
382
|
+
valid_privileges, valid_roles = self.list_valid_privileges_and_roles()
|
|
383
|
+
roles = [
|
|
384
|
+
role
|
|
385
|
+
for role in extra_user_roles
|
|
386
|
+
if (
|
|
387
|
+
user == BACKUP_USER
|
|
388
|
+
or user in SYSTEM_USERS
|
|
389
|
+
or role in valid_roles
|
|
390
|
+
or role == ACCESS_GROUP_RELATION
|
|
391
|
+
or role == "createdb"
|
|
392
|
+
)
|
|
393
|
+
]
|
|
394
|
+
if "createdb" in extra_user_roles:
|
|
395
|
+
extra_user_roles.remove("createdb")
|
|
396
|
+
roles.remove("createdb")
|
|
397
|
+
extra_user_roles.append(ROLE_DATABASES_OWNER)
|
|
398
|
+
roles.append(ROLE_DATABASES_OWNER)
|
|
399
|
+
privileges = {
|
|
400
|
+
extra_user_role
|
|
401
|
+
for extra_user_role in extra_user_roles
|
|
402
|
+
if extra_user_role and extra_user_role not in roles
|
|
403
|
+
}
|
|
404
|
+
invalid_privileges = [
|
|
405
|
+
privilege for privilege in privileges if privilege not in valid_privileges
|
|
406
|
+
]
|
|
407
|
+
if len(invalid_privileges) > 0:
|
|
408
|
+
logger.error(f"Invalid extra user roles: {', '.join(privileges)}")
|
|
409
|
+
raise PostgreSQLCreateUserError(INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE)
|
|
410
|
+
return roles, privileges
|
|
411
|
+
|
|
412
|
+
def delete_user(self, user: str) -> None:
|
|
413
|
+
"""Deletes a database user.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
user: user to be deleted.
|
|
417
|
+
"""
|
|
418
|
+
# First of all, check whether the user exists. Otherwise, do nothing.
|
|
419
|
+
users = self.list_users()
|
|
420
|
+
if user not in users:
|
|
421
|
+
return
|
|
422
|
+
|
|
423
|
+
# List all databases.
|
|
424
|
+
try:
|
|
425
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
426
|
+
cursor.execute("SELECT datname FROM pg_database WHERE datistemplate = false;")
|
|
427
|
+
databases = [row[0] for row in cursor.fetchall()]
|
|
428
|
+
|
|
429
|
+
# Existing objects need to be reassigned in each database
|
|
430
|
+
# before the user can be deleted.
|
|
431
|
+
for database in databases:
|
|
432
|
+
with self._connect_to_database(
|
|
433
|
+
database
|
|
434
|
+
) as connection, connection.cursor() as cursor:
|
|
435
|
+
cursor.execute(SQL("RESET ROLE;"))
|
|
436
|
+
cursor.execute(
|
|
437
|
+
SQL("REASSIGN OWNED BY {} TO {};").format(
|
|
438
|
+
Identifier(user), Identifier(self.user)
|
|
439
|
+
)
|
|
440
|
+
)
|
|
441
|
+
cursor.execute(SQL("DROP OWNED BY {};").format(Identifier(user)))
|
|
442
|
+
|
|
443
|
+
# Delete the user.
|
|
444
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
445
|
+
cursor.execute(SQL("RESET ROLE;"))
|
|
446
|
+
cursor.execute(SQL("DROP ROLE {};").format(Identifier(user)))
|
|
447
|
+
except psycopg2.Error as e:
|
|
448
|
+
logger.error(f"Failed to delete user: {e}")
|
|
449
|
+
raise PostgreSQLDeleteUserError() from e
|
|
450
|
+
|
|
451
|
+
def enable_disable_extensions(
|
|
452
|
+
self, extensions: Dict[str, bool], database: Optional[str] = None
|
|
453
|
+
) -> None:
|
|
454
|
+
"""Enables or disables a PostgreSQL extension.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
extensions: the name of the extensions.
|
|
458
|
+
database: optional database where to enable/disable the extension.
|
|
459
|
+
|
|
460
|
+
Raises:
|
|
461
|
+
PostgreSQLEnableDisableExtensionError if the operation fails.
|
|
462
|
+
"""
|
|
463
|
+
connection = None
|
|
464
|
+
try:
|
|
465
|
+
if database is not None:
|
|
466
|
+
databases = [database]
|
|
467
|
+
else:
|
|
468
|
+
# Retrieve all the databases.
|
|
469
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
470
|
+
cursor.execute("SELECT datname FROM pg_database WHERE NOT datistemplate;")
|
|
471
|
+
databases = {database[0] for database in cursor.fetchall()}
|
|
472
|
+
|
|
473
|
+
ordered_extensions = OrderedDict()
|
|
474
|
+
for plugin in DEPENDENCY_PLUGINS:
|
|
475
|
+
ordered_extensions[plugin] = extensions.get(plugin, False)
|
|
476
|
+
for extension, enable in extensions.items():
|
|
477
|
+
ordered_extensions[extension] = enable
|
|
478
|
+
|
|
479
|
+
self._configure_pgaudit(False)
|
|
480
|
+
|
|
481
|
+
# Enable/disabled the extension in each database.
|
|
482
|
+
for database in databases:
|
|
483
|
+
with self._connect_to_database(
|
|
484
|
+
database=database
|
|
485
|
+
) as connection, connection.cursor() as cursor:
|
|
486
|
+
for extension, enable in ordered_extensions.items():
|
|
487
|
+
cursor.execute(
|
|
488
|
+
f"CREATE EXTENSION IF NOT EXISTS {extension};"
|
|
489
|
+
if enable
|
|
490
|
+
else f"DROP EXTENSION IF EXISTS {extension};"
|
|
491
|
+
)
|
|
492
|
+
self._configure_pgaudit(ordered_extensions.get("pgaudit", False))
|
|
493
|
+
except psycopg2.errors.UniqueViolation: # type: ignore
|
|
494
|
+
pass
|
|
495
|
+
except psycopg2.errors.DependentObjectsStillExist: # type: ignore
|
|
496
|
+
raise
|
|
497
|
+
except psycopg2.Error as e:
|
|
498
|
+
raise PostgreSQLEnableDisableExtensionError() from e
|
|
499
|
+
finally:
|
|
500
|
+
if connection is not None:
|
|
501
|
+
connection.close()
|
|
502
|
+
|
|
503
|
+
def get_postgresql_version(self, current_host=True) -> str:
|
|
504
|
+
"""Returns the PostgreSQL version.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
PostgreSQL version number.
|
|
508
|
+
"""
|
|
509
|
+
host = self.current_host if current_host else None
|
|
510
|
+
try:
|
|
511
|
+
with self._connect_to_database(
|
|
512
|
+
database_host=host
|
|
513
|
+
) as connection, connection.cursor() as cursor:
|
|
514
|
+
cursor.execute("SELECT version();")
|
|
515
|
+
# Split to get only the version number. There should always be a version.
|
|
516
|
+
return cursor.fetchone()[0].split(" ")[1]
|
|
517
|
+
except psycopg2.Error as e:
|
|
518
|
+
logger.error(f"Failed to get PostgreSQL version: {e}")
|
|
519
|
+
raise PostgreSQLGetPostgreSQLVersionError() from e
|
|
520
|
+
|
|
521
|
+
def list_users(self, group: Optional[str] = None, current_host=False) -> Set[str]:
|
|
522
|
+
"""Returns the list of PostgreSQL database users.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
group: optional group to filter the users.
|
|
526
|
+
current_host: whether to check the current host
|
|
527
|
+
instead of the primary host.
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
List of PostgreSQL database users.
|
|
531
|
+
"""
|
|
532
|
+
connection = None
|
|
533
|
+
host = self.current_host if current_host else None
|
|
534
|
+
try:
|
|
535
|
+
with self._connect_to_database(
|
|
536
|
+
database_host=host
|
|
537
|
+
) as connection, connection.cursor() as cursor:
|
|
538
|
+
if group:
|
|
539
|
+
query = SQL(
|
|
540
|
+
"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;"
|
|
541
|
+
).format(Literal(group))
|
|
542
|
+
else:
|
|
543
|
+
query = "SELECT usename FROM pg_catalog.pg_user;"
|
|
544
|
+
cursor.execute(query)
|
|
545
|
+
usernames = cursor.fetchall()
|
|
546
|
+
return {username[0] for username in usernames}
|
|
547
|
+
except psycopg2.Error as e:
|
|
548
|
+
logger.error(f"Failed to list PostgreSQL database users: {e}")
|
|
549
|
+
raise PostgreSQLListUsersError() from e
|
|
550
|
+
finally:
|
|
551
|
+
if connection is not None:
|
|
552
|
+
connection.close()
|
|
553
|
+
|
|
554
|
+
def list_valid_privileges_and_roles(self) -> Tuple[Set[str], Set[str]]:
|
|
555
|
+
"""Returns two sets with valid privileges and roles.
|
|
556
|
+
|
|
557
|
+
Returns:
|
|
558
|
+
Tuple containing two sets: the first with valid privileges
|
|
559
|
+
and the second with valid roles.
|
|
560
|
+
"""
|
|
561
|
+
return {
|
|
562
|
+
"superuser",
|
|
563
|
+
}, ALLOWED_ROLES
|
|
564
|
+
|
|
565
|
+
def is_user_in_hba(self, username: str) -> bool:
|
|
566
|
+
"""Check if user was added in pg_hba."""
|
|
567
|
+
connection = None
|
|
568
|
+
try:
|
|
569
|
+
with self._connect_to_database() as connection, connection.cursor() as cursor:
|
|
570
|
+
cursor.execute(
|
|
571
|
+
SQL(
|
|
572
|
+
"SELECT COUNT(*) FROM pg_hba_file_rules WHERE {} = ANY(user_name);"
|
|
573
|
+
).format(Literal(username))
|
|
574
|
+
)
|
|
575
|
+
if result := cursor.fetchone():
|
|
576
|
+
return result[0] > 0
|
|
577
|
+
return False
|
|
578
|
+
except psycopg2.Error as e:
|
|
579
|
+
logger.debug(f"Failed to check pg_hba: {e}")
|
|
580
|
+
return False
|
|
581
|
+
finally:
|
|
582
|
+
if connection:
|
|
583
|
+
connection.close()
|