Skip to content

Commit d325da6

Browse files
committed
Add password rotation action
1 parent dd395f5 commit d325da6

File tree

4 files changed

+108
-6
lines changed

4 files changed

+108
-6
lines changed

actions.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,13 @@ get-primary:
66
get-operator-password:
77
description: Get the operator user password used by charm.
88
It is internal charm user, SHOULD NOT be used by applications.
9+
rotate-users-passwords:
10+
description: Change the operator user password used by charm.
11+
It is internal charm user, SHOULD NOT be used by applications.
12+
params:
13+
user:
14+
type: string
15+
description: The password will be auto-generated if this option is not specified.
16+
password:
17+
type: string
18+
description: The password will be auto-generated if this option is not specified.

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
Any charm using this library should import the `psycopg2` or `psycopg2-binary` dependency.
2020
"""
2121
import logging
22+
from typing import Dict
2223

2324
import psycopg2
2425
from psycopg2 import sql
@@ -31,7 +32,7 @@
3132

3233
# Increment this PATCH version before using `charmcraft publish-lib` or reset
3334
# to 0 if you are raising the major API version
34-
LIBPATCH = 1
35+
LIBPATCH = 2
3536

3637

3738
logger = logging.getLogger(__name__)
@@ -53,6 +54,10 @@ class PostgreSQLGetPostgreSQLVersionError(Exception):
5354
"""Exception raised when retrieving PostgreSQL version fails."""
5455

5556

57+
class PostgreSQLSetUserPasswordError(Exception):
58+
"""Exception raised when setting/updating a user password fails."""
59+
60+
5661
class PostgreSQL:
5762
"""Class to encapsulate all operations related to interacting with PostgreSQL instance."""
5863

@@ -68,20 +73,25 @@ def __init__(
6873
self.password = password
6974
self.database = database
7075

71-
def _connect_to_database(self, database: str = None) -> psycopg2.extensions.connection:
76+
def _connect_to_database(
77+
self, database: str = None, autocommit: bool = True
78+
) -> psycopg2.extensions.connection:
7279
"""Creates a connection to the database.
7380
7481
Args:
7582
database: database to connect to (defaults to the database
7683
provided when the object for this class was created).
84+
autocommit: whether every command issued to the database
85+
should be immediately committed.
7786
7887
Returns:
7988
psycopg2 connection object.
8089
"""
8190
connection = psycopg2.connect(
8291
f"dbname='{database if database else self.database}' user='{self.user}' host='{self.host}' password='{self.password}' connect_timeout=1"
8392
)
84-
connection.autocommit = True
93+
if autocommit:
94+
connection.autocommit = True
8595
return connection
8696

8797
def create_database(self, database: str, user: str) -> None:
@@ -173,3 +183,30 @@ def get_postgresql_version(self) -> str:
173183
except psycopg2.Error as e:
174184
logger.error(f"Failed to get PostgreSQL version: {e}")
175185
raise PostgreSQLGetPostgreSQLVersionError()
186+
187+
def rotate_users_passwords(self, users_with_passwords: Dict[str, str]) -> None:
188+
"""Rotates one or more users passwords.
189+
190+
Args:
191+
users_with_passwords: a dict following the format {"user": "password"}.
192+
It can contain multiple users.
193+
194+
Raises:
195+
PostgreSQLSetUserPasswordError if any user password couldn't be changed.
196+
"""
197+
try:
198+
with self._connect_to_database(
199+
autocommit=False
200+
) as connection, connection.cursor() as cursor:
201+
for user, password in users_with_passwords.items():
202+
cursor.execute(
203+
sql.SQL(
204+
"ALTER USER {} WITH ENCRYPTED PASSWORD '" + password + "';"
205+
).format(sql.Identifier(user))
206+
)
207+
except psycopg2.Error as e:
208+
logger.error(f"Failed to rotate user password: {e}")
209+
raise PostgreSQLSetUserPasswordError()
210+
finally:
211+
if connection is not None:
212+
connection.close()

src/charm.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
import logging
88
from typing import List
99

10-
from charms.postgresql_k8s.v0.postgresql import PostgreSQL
10+
from charms.postgresql_k8s.v0.postgresql import (
11+
PostgreSQL,
12+
PostgreSQLSetUserPasswordError,
13+
)
1114
from lightkube import ApiError, Client, codecs
1215
from lightkube.resources.core_v1 import Pod
1316
from ops.charm import (
@@ -30,7 +33,7 @@
3033
from requests import ConnectionError
3134
from tenacity import RetryError
3235

33-
from constants import PEER, USER
36+
from constants import PEER, REPLICATION_USER, SYSTEM_USERS, USER
3437
from patroni import NotReadyError, Patroni
3538
from relations.db import DbProvides
3639
from relations.postgresql_provider import PostgreSQLProvider
@@ -61,6 +64,9 @@ def __init__(self, *args):
6164
self.framework.observe(
6265
self.on.get_operator_password_action, self._on_get_operator_password
6366
)
67+
self.framework.observe(
68+
self.on.rotate_users_passwords_action, self._on_rotate_users_passwords
69+
)
6470
self.framework.observe(self.on.get_primary_action, self._on_get_primary)
6571
self.framework.observe(self.on.update_status, self._on_update_status)
6672
self._storage_path = self.meta.storages["pgdata"].location
@@ -391,6 +397,53 @@ def _on_get_operator_password(self, event: ActionEvent) -> None:
391397
"""Returns the password for the operator user as an action response."""
392398
event.set_results({"operator-password": self._get_operator_password()})
393399

400+
def _on_rotate_users_passwords(self, event: ActionEvent) -> None:
401+
"""Rotate the password for all system users or the specified user."""
402+
# Only the leader can write the new password into peer relation.
403+
if not self.unit.is_leader():
404+
event.fail("The action can be run only on the leader unit")
405+
return
406+
407+
if "user" in event.params:
408+
user = event.params["user"]
409+
410+
# Fail if the user is not a system user.
411+
# One example is users created through relations.
412+
if user not in SYSTEM_USERS:
413+
event.fail(f"User {user} is not a system user")
414+
return
415+
416+
# Generate a new password and use it if no password was provided to the action.
417+
users = {
418+
user: event.params["password"] if "password" in event.params else new_password()
419+
}
420+
else:
421+
if "password" in event.params:
422+
event.fail("The same password cannot be set for multiple users")
423+
return
424+
425+
users = {user: new_password() for user in SYSTEM_USERS}
426+
427+
try:
428+
self.postgresql.rotate_users_passwords(users)
429+
except PostgreSQLSetUserPasswordError as e:
430+
event.fail(f"Failed to set user password with error {e}")
431+
return
432+
433+
# Update the password in the peer relation if the operation was successful.
434+
for user, password in users.items():
435+
self._peers.data[self.app].update({f"{user}-password": password})
436+
437+
# for unit in
438+
self._patroni.reload_patroni_configuration()
439+
440+
# Return the generated password when the user option is given.
441+
if "user" in event.params and "password" not in event.params:
442+
user = event.params["user"]
443+
event.set_results(
444+
{f"{user}-password": self._peers.data[self.app].get(f"{user}-password")}
445+
)
446+
394447
def _on_get_primary(self, event: ActionEvent) -> None:
395448
"""Get primary instance."""
396449
try:
@@ -499,7 +552,7 @@ def _postgresql_layer(self) -> Layer:
499552
"PATRONI_KUBERNETES_USE_ENDPOINTS": "true",
500553
"PATRONI_NAME": pod_name,
501554
"PATRONI_SCOPE": f"patroni-{self._name}",
502-
"PATRONI_REPLICATION_USERNAME": "replication",
555+
"PATRONI_REPLICATION_USERNAME": REPLICATION_USER,
503556
"PATRONI_REPLICATION_PASSWORD": self._replication_password,
504557
"PATRONI_SUPERUSER_USERNAME": USER,
505558
"PATRONI_SUPERUSER_PASSWORD": self._get_operator_password(),

src/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@
55

66
DATABASE_PORT = "5432"
77
PEER = "database-peers"
8+
REPLICATION_USER = "replication"
89
USER = "operator"
10+
SYSTEM_USERS = [REPLICATION_USER, USER]

0 commit comments

Comments
 (0)