Skip to content

Commit 50bf787

Browse files
Implement instance level predefined roles
1 parent 421e013 commit 50bf787

File tree

8 files changed

+116
-25
lines changed

8 files changed

+116
-25
lines changed

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636
# Increment this PATCH version before using `charmcraft publish-lib` or reset
3737
# to 0 if you are raising the major API version
38-
LIBPATCH = 52
38+
LIBPATCH = 53
3939

4040
# Groups to distinguish HBA access
4141
ACCESS_GROUP_IDENTITY = "identity_access"
@@ -49,6 +49,11 @@
4949
ACCESS_GROUP_RELATION,
5050
]
5151

52+
ROLE_STATS = "charmed_stats"
53+
ROLE_READ = "charmed_read"
54+
ROLE_DML = "charmed_dml"
55+
ROLE_BACKUP = "charmed_backup"
56+
5257
# Groups to distinguish database permissions
5358
PERMISSIONS_GROUP_ADMIN = "admin"
5459

@@ -125,6 +130,14 @@ class PostgreSQLUpdateUserPasswordError(Exception):
125130
"""Exception raised when updating a user password fails."""
126131

127132

133+
class PostgreSQLCreatePredefinedRolesError(Exception):
134+
"""Exception raised when creating predefined roles."""
135+
136+
137+
class PostgreSQLGrantDatabasePrivilegesToUserError(Exception):
138+
"""Exception raised when granting database privileges to user."""
139+
140+
128141
class PostgreSQL:
129142
"""Class to encapsulate all operations related to interacting with PostgreSQL instance."""
130143

@@ -326,6 +339,60 @@ def create_user(
326339
logger.error(f"Failed to create user: {e}")
327340
raise PostgreSQLCreateUserError() from e
328341

342+
def create_predefined_roles(self) -> None:
343+
"""Create predefined roles."""
344+
role_to_queries = {
345+
ROLE_STATS: [
346+
f"CREATE ROLE {ROLE_STATS} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE pg_monitor",
347+
],
348+
ROLE_READ: [
349+
f"CREATE ROLE {ROLE_READ} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE pg_read_all_data",
350+
],
351+
ROLE_DML: [
352+
f"CREATE ROLE {ROLE_DML} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE pg_write_all_data",
353+
],
354+
ROLE_BACKUP: [
355+
f"CREATE ROLE {ROLE_BACKUP} NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOLOGIN IN ROLE pg_checkpoint",
356+
f"GRANT {ROLE_STATS} TO {ROLE_BACKUP}",
357+
f"GRANT execute ON FUNCTION pg_backup_start TO {ROLE_BACKUP}",
358+
f"GRANT execute ON FUNCTION pg_backup_stop TO {ROLE_BACKUP}",
359+
f"GRANT execute ON FUNCTION pg_create_restore_point TO {ROLE_BACKUP}",
360+
f"GRANT execute ON FUNCTION pg_switch_wal TO {ROLE_BACKUP}",
361+
],
362+
}
363+
364+
_, existing_roles = self.list_valid_privileges_and_roles()
365+
366+
try:
367+
with self._connect_to_database() as connection, connection.cursor() as cursor:
368+
for role, queries in role_to_queries.items():
369+
if role in existing_roles:
370+
logger.debug(f"Role {role} already exists")
371+
continue
372+
373+
logger.info(f"Creating predefined role {role}")
374+
375+
for query in queries:
376+
cursor.execute(SQL(query))
377+
except psycopg2.Error as e:
378+
logger.error(f"Failed to create predefined roles: {e}")
379+
raise PostgreSQLCreatePredefinedRolesError() from e
380+
381+
def grant_database_privileges_to_user(
382+
self, user: str, database: str, privileges: list[str]
383+
) -> None:
384+
"""Grant the specified privileges on the provided database for the user."""
385+
try:
386+
with self._connect_to_database() as connection, connection.cursor() as cursor:
387+
cursor.execute(
388+
SQL("GRANT {} ON DATABASE {} TO {};").format(
389+
Identifier(", ".join(privileges)), Identifier(database), Identifier(user)
390+
)
391+
)
392+
except psycopg2.Error as e:
393+
logger.error(f"Failed to grant privileges to user: {e}")
394+
raise PostgreSQLGrantDatabasePrivilegesToUserError() from e
395+
329396
def delete_user(self, user: str) -> None:
330397
"""Deletes a database user.
331398
@@ -727,7 +794,7 @@ def set_up_database(self, temp_location: Optional[str] = None) -> None:
727794
)
728795
self.create_user(
729796
PERMISSIONS_GROUP_ADMIN,
730-
extra_user_roles=["pg_read_all_data", "pg_write_all_data"],
797+
extra_user_roles=[ROLE_READ, ROLE_DML],
731798
)
732799
cursor.execute("GRANT CONNECT ON DATABASE postgres TO admin;")
733800
except psycopg2.Error as e:

src/backups.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from ops import HookEvent
2323
from ops.charm import ActionEvent
2424
from ops.framework import Object
25-
from ops.jujuversion import JujuVersion
2625
from ops.model import ActiveStatus, MaintenanceStatus
2726
from ops.pebble import ChangeError, ExecError
2827
from tenacity import RetryError, Retrying, stop_after_attempt, wait_fixed
@@ -793,12 +792,11 @@ def _on_create_backup_action(self, event) -> None: # noqa: C901
793792

794793
# Test uploading metadata to S3 to test credentials before backup.
795794
datetime_backup_requested = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
796-
juju_version = JujuVersion.from_environ()
797795
metadata = f"""Date Backup Requested: {datetime_backup_requested}
798796
Model Name: {self.model.name}
799797
Application Name: {self.model.app.name}
800798
Unit Name: {self.charm.unit.name}
801-
Juju Version: {juju_version!s}
799+
Juju Version: {self.charm.model.juju_version!s}
802800
"""
803801
if not self._upload_content_to_s3(
804802
metadata,

src/charm.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@
3939
ACCESS_GROUP_IDENTITY,
4040
ACCESS_GROUPS,
4141
REQUIRED_PLUGINS,
42+
ROLE_BACKUP,
43+
ROLE_STATS,
4244
PostgreSQL,
45+
PostgreSQLCreatePredefinedRolesError,
4346
PostgreSQLEnableDisableExtensionError,
4447
PostgreSQLGetCurrentTimelineError,
4548
PostgreSQLUpdateUserPasswordError,
@@ -1134,16 +1137,26 @@ def _initialize_cluster(self, event: WorkloadEvent) -> bool:
11341137
event.defer()
11351138
return False
11361139

1140+
try:
1141+
self.postgresql.create_predefined_roles()
1142+
except PostgreSQLCreatePredefinedRolesError as e:
1143+
logger.exception(e)
1144+
self.unit.status = BlockedStatus("Failed to create pre-defined roles")
1145+
return
1146+
11371147
pg_users = self.postgresql.list_users()
11381148
# Create the backup user.
11391149
if BACKUP_USER not in pg_users:
1140-
self.postgresql.create_user(BACKUP_USER, new_password(), admin=True)
1150+
self.postgresql.create_user(
1151+
BACKUP_USER, new_password(), extra_user_roles=[ROLE_BACKUP]
1152+
)
1153+
self.postgresql.grant_database_privileges_to_user(BACKUP_USER, "postgres", ["connect"])
11411154
# Create the monitoring user.
11421155
if MONITORING_USER not in pg_users:
11431156
self.postgresql.create_user(
11441157
MONITORING_USER,
11451158
self.get_secret(APP_SCOPE, MONITORING_PASSWORD_KEY),
1146-
extra_user_roles=["pg_monitor"],
1159+
extra_user_roles=[ROLE_STATS],
11471160
)
11481161

11491162
self.postgresql.set_up_database(temp_location="/var/lib/postgresql/temp")
@@ -1308,13 +1321,14 @@ def _update_admin_password(self, admin_secret_id: str) -> None:
13081321
return
13091322

13101323
try:
1324+
updateable_users = [*SYSTEM_USERS, BACKUP_USER]
13111325
# get the secret content and check each user configured there
13121326
# only SYSTEM_USERS with changed passwords are processed, all others ignored
13131327
updated_passwords = self.get_secret_from_id(secret_id=admin_secret_id)
13141328
for user, password in list(updated_passwords.items()):
1315-
if user not in SYSTEM_USERS:
1329+
if user not in updateable_users:
13161330
logger.error(
1317-
f"Can only update system users: {', '.join(SYSTEM_USERS)} not {user}"
1331+
f"Can only update system users: {', '.join(updateable_users)} not {user}"
13181332
)
13191333
updated_passwords.pop(user)
13201334
continue

src/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"/var/log/postgresql/postgresql*.log",
2727
]
2828
# List of system usernames needed for correct work of the charm/workload.
29-
SYSTEM_USERS = [BACKUP_USER, REPLICATION_USER, REWIND_USER, USER, MONITORING_USER]
29+
SYSTEM_USERS = [REPLICATION_USER, REWIND_USER, USER, MONITORING_USER]
3030

3131
# Labels are not confidential
3232
REPLICATION_PASSWORD_KEY = "replication-password" # noqa: S105

src/relations/postgresql_provider.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None:
156156
self.charm.unit.status = BlockedStatus(
157157
e.message
158158
if issubclass(type(e), PostgreSQLCreateUserError) and e.message is not None
159-
else f"Failed to initialize {self.relation_name} relation"
159+
else f"Failed to initialize relation {self.relation_name}"
160160
)
161161

162162
def _on_relation_departed(self, event: RelationDepartedEvent) -> None:
@@ -291,6 +291,11 @@ def _update_unit_status(self, relation: Relation) -> None:
291291
and not self.check_for_invalid_extra_user_roles(relation.id)
292292
):
293293
self.charm.unit.status = ActiveStatus()
294+
if (
295+
self.charm.is_blocked
296+
and "Failed to initialize relation" in self.charm.unit.status.message
297+
):
298+
self.charm.unit.status = ActiveStatus()
294299

295300
self._update_unit_status_on_blocking_endpoint_simultaneously()
296301

src/upgrade.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@
1212
DependencyModel,
1313
KubernetesClientError,
1414
)
15-
from charms.postgresql_k8s.v0.postgresql import ACCESS_GROUPS
15+
from charms.postgresql_k8s.v0.postgresql import (
16+
ACCESS_GROUPS,
17+
ROLE_STATS,
18+
PostgreSQLCreatePredefinedRolesError,
19+
)
1620
from lightkube.core.client import Client
1721
from lightkube.core.exceptions import ApiError
1822
from lightkube.resources.apps_v1 import StatefulSet
@@ -292,9 +296,16 @@ def _set_up_new_credentials_for_legacy(self) -> None:
292296
self.charm.postgresql.create_user(
293297
MONITORING_USER,
294298
self.charm.get_secret(APP_SCOPE, MONITORING_PASSWORD_KEY),
295-
extra_user_roles="pg_monitor",
299+
extra_user_roles=[ROLE_STATS],
296300
)
297301

302+
try:
303+
self.postgresql.create_predefined_roles()
304+
except PostgreSQLCreatePredefinedRolesError as e:
305+
logger.exception(e)
306+
self.unit.status = BlockedStatus("Failed to create pre-defined roles")
307+
return
308+
298309
@property
299310
def unit_upgrade_data(self) -> RelationDataContent:
300311
"""Return the application upgrade data."""

tests/unit/test_backups.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,7 +1197,6 @@ def test_on_create_backup_action(harness):
11971197
) as _is_primary,
11981198
patch("charm.PostgreSQLBackups._upload_content_to_s3") as _upload_content_to_s3,
11991199
patch("backups.datetime") as _datetime,
1200-
patch("ops.JujuVersion.from_environ") as _from_environ,
12011200
patch("charm.PostgreSQLBackups._retrieve_s3_parameters") as _retrieve_s3_parameters,
12021201
patch("charm.PostgreSQLBackups._can_unit_perform_backup") as _can_unit_perform_backup,
12031202
):
@@ -1233,13 +1232,12 @@ def test_on_create_backup_action(harness):
12331232
[],
12341233
)
12351234
_datetime.now.return_value.strftime.return_value = "2023-01-01T09:00:00Z"
1236-
_from_environ.return_value = "test-juju-version"
12371235
_upload_content_to_s3.return_value = False
12381236
expected_metadata = f"""Date Backup Requested: 2023-01-01T09:00:00Z
12391237
Model Name: {harness.charm.model.name}
12401238
Application Name: {harness.charm.model.app.name}
12411239
Unit Name: {harness.charm.unit.name}
1242-
Juju Version: test-juju-version
1240+
Juju Version: 0.0.0
12431241
"""
12441242
harness.charm.backup._on_create_backup_action(mock_event)
12451243
_upload_content_to_s3.assert_called_once_with(

tests/unit/test_postgresql.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ def test_create_database(harness):
8080
harness.charm.postgresql.create_database(database, user, plugins, client_relations)
8181
execute = _connect_to_database.return_value.cursor.return_value.execute
8282
execute.assert_has_calls([
83+
call(
84+
Composed([
85+
SQL("SELECT datname FROM pg_database WHERE datname="),
86+
Literal(database),
87+
SQL(";"),
88+
]),
89+
),
8390
call(
8491
Composed([
8592
SQL("REVOKE ALL PRIVILEGES ON DATABASE "),
@@ -105,15 +112,6 @@ def test_create_database(harness):
105112
SQL(";"),
106113
])
107114
),
108-
call(
109-
Composed([
110-
SQL("GRANT ALL PRIVILEGES ON DATABASE "),
111-
Identifier(database),
112-
SQL(" TO "),
113-
Identifier(BACKUP_USER),
114-
SQL(";"),
115-
])
116-
),
117115
call(
118116
Composed([
119117
SQL("GRANT ALL PRIVILEGES ON DATABASE "),

0 commit comments

Comments
 (0)