Skip to content

Commit 9128d34

Browse files
Password rotation (canonical#26)
1 parent 97ae9fb commit 9128d34

File tree

11 files changed

+408
-61
lines changed

11 files changed

+408
-61
lines changed

actions.yaml

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,22 @@
33

44
get-primary:
55
description: Get the unit with is the primary/leader in the replication.
6-
get-operator-password:
7-
description: Get the operator user password used by charm.
8-
It is internal charm user, SHOULD NOT be used by applications.
6+
get-password:
7+
description: Change the system user's password, which is used by charm.
8+
It is for internal charm users and SHOULD NOT be used by applications.
9+
params:
10+
username:
11+
type: string
12+
description: The username, the default value 'operator'.
13+
Possible values - operator, replication.
14+
set-password:
15+
description: Change the system user's password, which is used by charm.
16+
It is for internal charm users and SHOULD NOT be used by applications.
17+
params:
18+
username:
19+
type: string
20+
description: The username, the default value 'operator'.
21+
Possible values - operator, replication.
22+
password:
23+
type: string
24+
description: The password will be auto-generated if this option is not specified.

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

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

3636

3737
logger = logging.getLogger(__name__)
@@ -53,6 +53,10 @@ class PostgreSQLGetPostgreSQLVersionError(Exception):
5353
"""Exception raised when retrieving PostgreSQL version fails."""
5454

5555

56+
class PostgreSQLUpdateUserPasswordError(Exception):
57+
"""Exception raised when updating a user password fails."""
58+
59+
5660
class PostgreSQL:
5761
"""Class to encapsulate all operations related to interacting with PostgreSQL instance."""
5862

@@ -173,3 +177,27 @@ def get_postgresql_version(self) -> str:
173177
except psycopg2.Error as e:
174178
logger.error(f"Failed to get PostgreSQL version: {e}")
175179
raise PostgreSQLGetPostgreSQLVersionError()
180+
181+
def update_user_password(self, username: str, password: str) -> None:
182+
"""Update a user password.
183+
184+
Args:
185+
username: the user to update the password.
186+
password: the new password for the user.
187+
188+
Raises:
189+
PostgreSQLUpdateUserPasswordError if the password couldn't be changed.
190+
"""
191+
try:
192+
with self._connect_to_database() as connection, connection.cursor() as cursor:
193+
cursor.execute(
194+
sql.SQL("ALTER USER {} WITH ENCRYPTED PASSWORD '" + password + "';").format(
195+
sql.Identifier(username)
196+
)
197+
)
198+
except psycopg2.Error as e:
199+
logger.error(f"Failed to update user password: {e}")
200+
raise PostgreSQLUpdateUserPasswordError()
201+
finally:
202+
if connection is not None:
203+
connection.close()

src/charm.py

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

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

33-
from constants import PEER, REPLICATION_PASSWORD_KEY, USER, USER_PASSWORD_KEY
36+
from constants import (
37+
PEER,
38+
REPLICATION_PASSWORD_KEY,
39+
REPLICATION_USER,
40+
SYSTEM_USERS,
41+
USER,
42+
USER_PASSWORD_KEY,
43+
)
3444
from patroni import NotReadyError, Patroni
3545
from relations.db import DbProvides
3646
from relations.postgresql_provider import PostgreSQLProvider
@@ -59,9 +69,8 @@ def __init__(self, *args):
5969
self.framework.observe(self.on.postgresql_pebble_ready, self._on_postgresql_pebble_ready)
6070
self.framework.observe(self.on.stop, self._on_stop)
6171
self.framework.observe(self.on.upgrade_charm, self._on_upgrade_charm)
62-
self.framework.observe(
63-
self.on.get_operator_password_action, self._on_get_operator_password
64-
)
72+
self.framework.observe(self.on.get_password_action, self._on_get_password)
73+
self.framework.observe(self.on.set_password_action, self._on_set_password)
6574
self.framework.observe(self.on.get_primary_action, self._on_get_primary)
6675
self.framework.observe(self.on.update_status, self._on_update_status)
6776
self._storage_path = self.meta.storages["pgdata"].location
@@ -118,7 +127,7 @@ def postgresql(self) -> PostgreSQL:
118127
return PostgreSQL(
119128
host=self.primary_endpoint,
120129
user=USER,
121-
password=self._get_operator_password(),
130+
password=self._get_secret("app", f"{USER}-password"),
122131
database="postgres",
123132
)
124133

@@ -426,9 +435,74 @@ def _create_resources(self) -> None:
426435
self.unit.status = BlockedStatus(f"failed to create services {e}")
427436
return
428437

429-
def _on_get_operator_password(self, event: ActionEvent) -> None:
430-
"""Returns the password for the operator user as an action response."""
431-
event.set_results({USER_PASSWORD_KEY: self._get_operator_password()})
438+
def _on_get_password(self, event: ActionEvent) -> None:
439+
"""Returns the password for a user as an action response.
440+
441+
If no user is provided, the password of the operator user is returned.
442+
"""
443+
username = event.params.get("username", USER)
444+
if username not in SYSTEM_USERS:
445+
event.fail(
446+
f"The action can be run only for users used by the charm or Patroni:"
447+
f" {', '.join(SYSTEM_USERS)} not {username}"
448+
)
449+
return
450+
event.set_results(
451+
{f"{username}-password": self._get_secret("app", f"{username}-password")}
452+
)
453+
454+
def _on_set_password(self, event: ActionEvent) -> None:
455+
"""Set the password for the specified user."""
456+
# Only leader can write the new password into peer relation.
457+
if not self.unit.is_leader():
458+
event.fail("The action can be run only on leader unit")
459+
return
460+
461+
username = event.params.get("username", USER)
462+
if username not in SYSTEM_USERS:
463+
event.fail(
464+
f"The action can be run only for users used by the charm:"
465+
f" {', '.join(SYSTEM_USERS)} not {username}"
466+
)
467+
return
468+
469+
password = new_password()
470+
if "password" in event.params:
471+
password = event.params["password"]
472+
473+
if password == self._get_secret("app", f"{username}-password"):
474+
event.log("The old and new passwords are equal.")
475+
event.set_results({f"{username}-password": password})
476+
return
477+
478+
# Ensure all members are ready before trying to reload Patroni
479+
# configuration to avoid errors (like the API not responding in
480+
# one instance because PostgreSQL and/or Patroni are not ready).
481+
if not self._patroni.are_all_members_ready():
482+
event.fail(
483+
"Failed changing the password: Not all members healthy or finished initial sync."
484+
)
485+
return
486+
487+
# Update the password in the PostgreSQL instance.
488+
try:
489+
self.postgresql.update_user_password(username, password)
490+
except PostgreSQLUpdateUserPasswordError as e:
491+
logger.exception(e)
492+
event.fail(
493+
"Failed changing the password: Not all members healthy or finished initial sync."
494+
)
495+
return
496+
497+
# Update the password in the secret store.
498+
self._set_secret("app", f"{username}-password", password)
499+
500+
# Update and reload Patroni configuration in this unit to use the new password.
501+
# Other units Patroni configuration will be reloaded in the peer relation changed event.
502+
self._patroni.render_patroni_yml_file()
503+
self._patroni.reload_patroni_configuration()
504+
505+
event.set_results({f"{username}-password": password})
432506

433507
def _on_get_primary(self, event: ActionEvent) -> None:
434508
"""Get primary instance."""
@@ -495,6 +569,8 @@ def _patroni(self):
495569
self._namespace,
496570
self.app.planned_units(),
497571
self._storage_path,
572+
self._get_secret("app", USER_PASSWORD_KEY),
573+
self._get_secret("app", REPLICATION_PASSWORD_KEY),
498574
)
499575

500576
@property
@@ -555,10 +631,8 @@ def _postgresql_layer(self) -> Layer:
555631
"PATRONI_KUBERNETES_USE_ENDPOINTS": "true",
556632
"PATRONI_NAME": pod_name,
557633
"PATRONI_SCOPE": f"patroni-{self._name}",
558-
"PATRONI_REPLICATION_USERNAME": "replication",
559-
"PATRONI_REPLICATION_PASSWORD": self._replication_password,
634+
"PATRONI_REPLICATION_USERNAME": REPLICATION_USER,
560635
"PATRONI_SUPERUSER_USERNAME": USER,
561-
"PATRONI_SUPERUSER_PASSWORD": self._get_operator_password(),
562636
},
563637
}
564638
},
@@ -575,15 +649,6 @@ def _peers(self) -> Relation:
575649
"""
576650
return self.model.get_relation(PEER)
577651

578-
def _get_operator_password(self) -> str:
579-
"""Get operator user password."""
580-
return self._get_secret("app", USER_PASSWORD_KEY)
581-
582-
@property
583-
def _replication_password(self) -> str:
584-
"""Get replication user password."""
585-
return self._get_secret("app", REPLICATION_PASSWORD_KEY)
586-
587652
def _unit_name_to_pod_name(self, unit_name: str) -> str:
588653
"""Converts unit name to pod name.
589654

src/constants.py

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

66
DATABASE_PORT = "5432"
77
PEER = "database-peers"
8+
REPLICATION_USER = "replication"
89
REPLICATION_PASSWORD_KEY = "replication-password"
910
USER = "operator"
1011
USER_PASSWORD_KEY = "operator-password"
12+
# List of system usernames needed for correct work of the charm/workload.
13+
SYSTEM_USERS = [REPLICATION_USER, USER]

src/patroni.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,22 @@ class Patroni:
3232
"""This class handles the communication with Patroni API and configuration files."""
3333

3434
def __init__(
35-
self, endpoint: str, endpoints: List[str], namespace: str, planned_units, storage_path: str
35+
self,
36+
endpoint: str,
37+
endpoints: List[str],
38+
namespace: str,
39+
planned_units,
40+
storage_path: str,
41+
superuser_password: str,
42+
replication_password: str,
3643
):
3744
self._endpoint = endpoint
3845
self._endpoints = endpoints
3946
self._namespace = namespace
4047
self._storage_path = storage_path
4148
self._planned_units = planned_units
49+
self._superuser_password = superuser_password
50+
self._replication_password = replication_password
4251

4352
def get_primary(self, unit_name_pattern=False) -> str:
4453
"""Get primary instance.
@@ -137,6 +146,8 @@ def render_patroni_yml_file(self) -> None:
137146
endpoints=self._endpoints,
138147
namespace=self._namespace,
139148
storage_path=self._storage_path,
149+
superuser_password=self._superuser_password,
150+
replication_password=self._replication_password,
140151
)
141152
self._render_file(f"{self._storage_path}/patroni.yml", rendered, 0o644)
142153

templates/patroni.yml.j2

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,9 @@ postgresql:
3030
{%- for endpoint in endpoints %}
3131
- host replication replication {{ endpoint }}.{{ namespace }}.svc.cluster.local md5
3232
{%- endfor %}
33+
authentication:
34+
replication:
35+
password: {{ replication_password }}
36+
superuser:
37+
password: {{ superuser_password }}
3338
use_endpoints: true

0 commit comments

Comments
 (0)