Skip to content

Commit 04b21d5

Browse files
[DPE-6752] Implement instance level predefined roles (#881)
* Implement instance level predefined roles * Fix minor bug introduced while rebasing off of 16/edge * Add integration test for charmed_read and charmed_dml roles * Revert all major changes except introduction of predefined roles * Sweep diff and minor bug fixes * Avoid creating set_user extension * Port Carl's fix for broken unit tests * Bump postgresql charm lib for 16/edge to v1 due to backwards incompatible changes * Re-add mistakenly removed patch statements
1 parent 6fa983a commit 04b21d5

File tree

18 files changed

+412
-54
lines changed

18 files changed

+412
-54
lines changed

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@
3131
LIBID = "24ee217a54e840a598ff21a079c3e678"
3232

3333
# Increment this major API version when introducing breaking changes
34-
LIBAPI = 0
34+
LIBAPI = 1
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 = 0
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,15 @@ 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+
141+
128142
class PostgreSQL:
129143
"""Class to encapsulate all operations related to interacting with PostgreSQL instance."""
130144

@@ -326,6 +340,55 @@ def create_user(
326340
logger.error(f"Failed to create user: {e}")
327341
raise PostgreSQLCreateUserError() from e
328342

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

poetry.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

refresh_versions.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@ name = "charmed-postgresql"
66

77
[snap.revisions]
88
# amd64
9-
x86_64 = "170"
9+
x86_64 = "182"
1010
# arm64
11-
aarch64 = "169"
11+
aarch64 = "181"

src/backups.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
from jinja2 import Template
2828
from ops.charm import ActionEvent, HookEvent
2929
from ops.framework import Object
30-
from ops.jujuversion import JujuVersion
3130
from ops.model import ActiveStatus, MaintenanceStatus
3231
from tenacity import RetryError, Retrying, stop_after_attempt, wait_fixed
3332

@@ -893,12 +892,11 @@ def _on_create_backup_action(self, event) -> None:
893892

894893
# Test uploading metadata to S3 to test credentials before backup.
895894
datetime_backup_requested = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
896-
juju_version = JujuVersion.from_environ()
897895
metadata = f"""Date Backup Requested: {datetime_backup_requested}
898896
Model Name: {self.model.name}
899897
Application Name: {self.model.app.name}
900898
Unit Name: {self.charm.unit.name}
901-
Juju Version: {juju_version!s}
899+
Juju Version: {self.charm.model.juju_version!s}
902900
"""
903901
if not self._upload_content_to_s3(
904902
metadata,

src/charm.py

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,14 @@
3333
ACCESS_GROUP_IDENTITY,
3434
ACCESS_GROUPS,
3535
REQUIRED_PLUGINS,
36+
ROLE_BACKUP,
37+
ROLE_STATS,
3638
PostgreSQL,
39+
PostgreSQLCreatePredefinedRolesError,
3740
PostgreSQLCreateUserError,
3841
PostgreSQLEnableDisableExtensionError,
3942
PostgreSQLGetCurrentTimelineError,
43+
PostgreSQLGrantDatabasePrivilegesToUserError,
4044
PostgreSQLListUsersError,
4145
PostgreSQLUpdateUserPasswordError,
4246
)
@@ -264,8 +268,7 @@ def __init__(self, *args):
264268
deleted_label=SECRET_DELETED_LABEL,
265269
)
266270

267-
run_cmd = "/usr/bin/juju-exec"
268-
self._observer = ClusterTopologyObserver(self, run_cmd)
271+
self._observer = ClusterTopologyObserver(self, "/usr/bin/juju-exec")
269272
self._rotate_logs = RotateLogs(self)
270273
self.framework.observe(self.on.cluster_topology_change, self._on_cluster_topology_change)
271274
self.framework.observe(self.on.install, self._on_install)
@@ -1682,7 +1685,7 @@ def _setup_ldap_sync(self, postgres_snap: snap.Snap | None = None) -> None:
16821685
logger.debug("Starting LDAP sync service")
16831686
postgres_snap.restart(services=["ldap-sync"])
16841687

1685-
def _start_primary(self, event: StartEvent) -> None:
1688+
def _start_primary(self, event: StartEvent) -> None: # noqa: C901
16861689
"""Bootstrap the cluster."""
16871690
# Set some information needed by Patroni to bootstrap the cluster.
16881691
if not self._patroni.bootstrap_cluster():
@@ -1696,6 +1699,25 @@ def _start_primary(self, event: StartEvent) -> None:
16961699
event.defer()
16971700
return
16981701

1702+
if not self._can_connect_to_postgresql:
1703+
logger.debug("Deferring on_start: awaiting for database to start")
1704+
self.unit.status = WaitingStatus("awaiting for database to start")
1705+
event.defer()
1706+
return
1707+
1708+
if not self.primary_endpoint:
1709+
logger.debug("Deferrring on_start: awaitng start of the primary")
1710+
self.unit.status = WaitingStatus("awaiting start of the primary")
1711+
event.defer()
1712+
return
1713+
1714+
try:
1715+
self.postgresql.create_predefined_roles()
1716+
except PostgreSQLCreatePredefinedRolesError as e:
1717+
logger.exception(e)
1718+
self.unit.status = BlockedStatus("Failed to create pre-defined roles")
1719+
return
1720+
16991721
# Create the default postgres database user that is needed for some
17001722
# applications (not charms) like Landscape Server.
17011723
try:
@@ -1704,16 +1726,25 @@ def _start_primary(self, event: StartEvent) -> None:
17041726
users = self.postgresql.list_users()
17051727
if "postgres" not in users:
17061728
self.postgresql.create_user("postgres", new_password(), admin=True)
1707-
# Create the backup user.
1729+
# Create the backup user.
17081730
if BACKUP_USER not in users:
1709-
self.postgresql.create_user(BACKUP_USER, new_password(), admin=True)
1731+
self.postgresql.create_user(
1732+
BACKUP_USER, new_password(), extra_user_roles=[ROLE_BACKUP]
1733+
)
1734+
self.postgresql.grant_database_privileges_to_user(
1735+
BACKUP_USER, "postgres", ["connect"]
1736+
)
17101737
if MONITORING_USER not in users:
17111738
# Create the monitoring user.
17121739
self.postgresql.create_user(
17131740
MONITORING_USER,
17141741
self.get_secret(APP_SCOPE, MONITORING_PASSWORD_KEY),
1715-
extra_user_roles=["pg_monitor"],
1742+
extra_user_roles=[ROLE_STATS],
17161743
)
1744+
except PostgreSQLGrantDatabasePrivilegesToUserError as e:
1745+
logger.exception(e)
1746+
self.unit.status = BlockedStatus("Failed to grant database privileges to user")
1747+
return
17171748
except PostgreSQLCreateUserError as e:
17181749
logger.exception(e)
17191750
self.set_unit_status(BlockedStatus("Failed to create postgres user"))
@@ -1796,13 +1827,14 @@ def _update_admin_password(self, admin_secret_id: str) -> None:
17961827
return
17971828

17981829
try:
1830+
updateable_users = [*SYSTEM_USERS, BACKUP_USER]
17991831
# get the secret content and check each user configured there
18001832
# only SYSTEM_USERS with changed passwords are processed, all others ignored
18011833
updated_passwords = self.get_secret_from_id(secret_id=admin_secret_id)
18021834
for user, password in list(updated_passwords.items()):
1803-
if user not in SYSTEM_USERS:
1835+
if user not in updateable_users:
18041836
logger.error(
1805-
f"Can only update system users: {', '.join(SYSTEM_USERS)} not {user}"
1837+
f"Can only update system users: {', '.join(updateable_users)} not {user}"
18061838
)
18071839
updated_passwords.pop(user)
18081840
continue

src/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
PATRONI_SERVICE_NAME = "snap.charmed-postgresql.patroni.service"
2828
PATRONI_SERVICE_DEFAULT_PATH = f"/etc/systemd/system/{PATRONI_SERVICE_NAME}"
2929
# List of system usernames needed for correct work of the charm/workload.
30-
SYSTEM_USERS = [BACKUP_USER, REPLICATION_USER, REWIND_USER, USER, MONITORING_USER]
30+
SYSTEM_USERS = [REPLICATION_USER, REWIND_USER, USER, MONITORING_USER]
3131

3232
# Snap constants.
3333
PGBACKREST_EXECUTABLE = "charmed-postgresql.pgbackrest"

src/relations/postgresql_provider.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ def _on_database_requested(self, event: DatabaseRequestedEvent) -> None:
143143
BlockedStatus(
144144
e.message
145145
if issubclass(type(e), PostgreSQLCreateUserError) and e.message is not None
146-
else f"Failed to initialize {self.relation_name} relation"
146+
else f"Failed to initialize relation {self.relation_name}"
147147
)
148148
)
149149

@@ -295,6 +295,11 @@ def _update_unit_status(self, relation: Relation) -> None:
295295
and self.charm.unit.status.message == INVALID_EXTRA_USER_ROLE_BLOCKING_MESSAGE
296296
) and not self.check_for_invalid_extra_user_roles(relation.id):
297297
self.charm.set_unit_status(ActiveStatus())
298+
if (
299+
self.charm.is_blocked
300+
and "Failed to initialize relation" in self.charm.unit.status.message
301+
):
302+
self.charm.set_unit_status(ActiveStatus())
298303

299304
self._update_unit_status_on_blocking_endpoint_simultaneously()
300305

tests/integration/ha_tests/helpers.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,11 +1078,24 @@ async def get_detached_storages(ops_test: OpsTest) -> list[str]:
10781078

10791079
async def check_password_auth(ops_test: OpsTest, unit_name: str) -> bool:
10801080
"""Checks if "operator" password is valid for current postgresql db."""
1081-
stdout = await run_command_on_unit(
1082-
ops_test,
1081+
complete_command = [
1082+
"exec",
1083+
"--unit",
10831084
unit_name,
1084-
"""grep -E 'password authentication failed for user' /var/snap/charmed-postgresql/common/var/log/postgresql/postgresql*""",
1085-
)
1085+
"--",
1086+
"grep",
1087+
"-E",
1088+
"'password'",
1089+
"/var/snap/charmed-postgresql/common/var/log/postgresql/postgresql*",
1090+
]
1091+
return_code, stdout, _ = await ops_test.juju(*complete_command)
1092+
if return_code != 0:
1093+
raise Exception(
1094+
"Expected command %s to succeed. Instead it failed: %s with code: ",
1095+
complete_command,
1096+
stdout,
1097+
return_code,
1098+
)
10861099
return 'password authentication failed for user "operator"' not in stdout
10871100

10881101

0 commit comments

Comments
 (0)