Skip to content

Commit 1b831fa

Browse files
committed
Initial authentication work.
1 parent d225c4c commit 1b831fa

File tree

8 files changed

+233
-85
lines changed

8 files changed

+233
-85
lines changed

poetry.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pydantic = "^2.11.4"
1414
cassandra-driver = "^3.29.2"
1515
tenacity = "^9.1.2"
1616
charmlibs-pathops = "^1.0.1"
17+
bcrypt = "^4.3.0"
1718

1819
[tool.poetry.group.format]
1920
optional = true

src/common/cassandra_client.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from contextlib import contextmanager
99
from typing import Generator
1010

11+
from bcrypt import gensalt, hashpw
1112
from cassandra.auth import PlainTextAuthProvider
1213
from cassandra.cluster import EXEC_PROFILE_DEFAULT, Cluster, ExecutionProfile, Session
1314
from cassandra.policies import DCAwareRoundRobinPolicy, TokenAwarePolicy
@@ -31,6 +32,22 @@ def __init__(self, hosts: list[str], user: str | None = None, password: str | No
3132

3233
return
3334

35+
def change_superuser_password(self, user: str, password: str) -> None:
36+
"""TODO."""
37+
with self._session() as session:
38+
session.execute(
39+
"UPDATE system_auth.roles SET salted_hash = %s WHERE role = %s",
40+
[
41+
hashpw(password.encode(), gensalt(prefix=b"2a")).decode(),
42+
user,
43+
],
44+
)
45+
46+
def change_user_password(self, user: str, password: str) -> None:
47+
"""TODO."""
48+
with self._session() as session:
49+
session.execute("ALTER USER %s WITH PASSWORD %s", [user, password])
50+
3451
@contextmanager
3552
def _session(self, keyspace: str | None = None) -> Generator[Session, None, None]:
3653
cluster = Cluster(

src/core/state.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ class UnitWorkloadState(StrEnum):
3838
"""Cassandra is installing."""
3939
WAITING_FOR_START = "waiting_for_start"
4040
"""Subordinate unit is waiting for leader to initialize cluster before it starts workload."""
41+
CHANGING_PASSWORD = "changing_password"
42+
"""Leader unit executes password change sequence before cluster is announced as ready."""
4143
STARTING = "starting"
4244
"""Cassandra is starting."""
4345
ACTIVE = "active"
@@ -176,6 +178,16 @@ def is_active(self) -> bool:
176178
"""Whether Cassandra cluster state is `ACTIVE`."""
177179
return self.state == ClusterState.ACTIVE
178180

181+
@property
182+
def cassandra_password_secret(self) -> str:
183+
"""TODO."""
184+
return self.relation_data.get("cassandra-password", "")
185+
186+
@cassandra_password_secret.setter
187+
def cassandra_password_secret(self, value: str) -> None:
188+
"""TODO."""
189+
self._field_setter_wrapper("cassandra-password", value)
190+
179191

180192
class ApplicationState(Object):
181193
"""Mappings for the charm relations that forms global application state."""
@@ -185,6 +197,7 @@ def __init__(self, charm: CharmBase):
185197
self.peer_app_interface = DataPeerData(
186198
self.model,
187199
relation_name=PEER_RELATION,
200+
additional_secret_fields=["cassandra-password"],
188201
)
189202
self.peer_unit_interface = DataPeerUnitData(self.model, relation_name=PEER_RELATION)
190203

src/core/statuses.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ class Status(Enum):
1616
INSTALLING = MaintenanceStatus("installing Cassandra")
1717
STARTING = MaintenanceStatus("waiting for Cassandra to start")
1818
WAITING_FOR_CLUSTER = WaitingStatus("waiting for cluster to start")
19+
CHANGING_PASSWORD = MaintenanceStatus("initializing authentication")
1920
INVALID_CONFIG = BlockedStatus("invalid config")

src/core/workload.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
"""Workload definition."""
66

7+
import secrets
8+
import string
79
from abc import ABC, abstractmethod
810
from typing import Literal
911

@@ -95,3 +97,12 @@ def path_exists(self, path: str) -> bool:
9597
def exec(self, command: list[str], suppress_error_log: bool = False) -> tuple[str, str]:
9698
"""Execute command."""
9799
pass
100+
101+
@staticmethod
102+
def generate_password() -> str:
103+
"""Create randomized string for use as app passwords.
104+
105+
Returns:
106+
String of 32 randomized letter+digit characters
107+
"""
108+
return "".join([secrets.choice(string.ascii_letters + string.digits) for _ in range(32)])

src/events/cassandra.py

Lines changed: 85 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
)
1818
from pydantic import ValidationError
1919

20+
from common.cassandra_client import CassandraClient
2021
from core.config import CharmConfig
2122
from core.state import ApplicationState, ClusterState, UnitWorkloadState
2223
from core.statuses import Status
@@ -56,35 +57,91 @@ def _on_install(self, _: InstallEvent) -> None:
5657
self.workload.install()
5758

5859
def _on_start(self, event: StartEvent) -> None:
59-
self.state.unit.workload_state = UnitWorkloadState.WAITING_FOR_START
6060
self._update_network_address()
6161

62-
if not self.charm.unit.is_leader() and not self.state.cluster.is_active:
63-
logger.debug("Deferring on_start for unit due to cluster isn't initialized yet")
64-
event.defer()
65-
return
66-
67-
if self.charm.unit.is_leader():
68-
self.state.cluster.seeds = [self.state.unit.peer_url]
69-
7062
try:
7163
self.config_manager.render_env(
7264
cassandra_limit_memory_mb=1024 if self.charm.config.profile == "testing" else None
7365
)
74-
self.config_manager.render_cassandra_config(
75-
cluster_name=self.charm.config.cluster_name,
76-
listen_address=self.state.unit.ip,
77-
seeds=self.state.cluster.seeds,
78-
)
7966
except ValidationError as e:
8067
logger.debug(f"Config haven't passed validation: {e}")
8168
event.defer()
8269
return
8370

71+
if not self.charm.unit.is_leader():
72+
self._start_subordinate(event)
73+
return
74+
75+
self.state.cluster.seeds = [self.state.unit.peer_url]
76+
77+
if self.state.unit.workload_state == UnitWorkloadState.CHANGING_PASSWORD:
78+
self._finalize_password_change(event)
79+
return
80+
81+
if not self.state.unit.workload_state:
82+
if self.state.cluster.cassandra_password_secret:
83+
self._start_leader()
84+
else:
85+
self._start_password_change(event)
86+
87+
def _start_subordinate(self, event: StartEvent) -> None:
88+
if not self.state.cluster.is_active:
89+
self.state.unit.workload_state = UnitWorkloadState.WAITING_FOR_START
90+
logger.debug("Deferring on_start for unit due to cluster isn't initialized yet")
91+
event.defer()
92+
return
93+
self.config_manager.render_cassandra_config(
94+
cluster_name=self.charm.config.cluster_name,
95+
listen_address=self.state.unit.ip,
96+
seeds=self.state.cluster.seeds,
97+
authentication=True,
98+
)
8499
self.workload.start()
85100
self.state.unit.workload_state = UnitWorkloadState.STARTING
86101

102+
def _start_leader(self) -> None:
103+
self.config_manager.render_cassandra_config(
104+
cluster_name=self.charm.config.cluster_name,
105+
listen_address=self.state.unit.ip,
106+
seeds=self.state.cluster.seeds,
107+
authentication=True,
108+
)
109+
self.workload.start()
110+
self.state.unit.workload_state = UnitWorkloadState.STARTING
111+
112+
def _start_password_change(self, event: StartEvent) -> None:
113+
self.config_manager.render_cassandra_config(
114+
cluster_name=self.charm.config.cluster_name,
115+
listen_address="127.0.0.1",
116+
seeds=["127.0.0.1:7000"],
117+
authentication=False,
118+
)
119+
self.workload.start()
120+
self.state.unit.workload_state = UnitWorkloadState.CHANGING_PASSWORD
121+
event.defer()
122+
123+
def _finalize_password_change(self, event: StartEvent) -> None:
124+
if not self.cluster_manager.is_healthy:
125+
event.defer()
126+
return
127+
password = self.workload.generate_password()
128+
self._cassandra.change_superuser_password("cassandra", password)
129+
self.state.cluster.cassandra_password_secret = password
130+
self.config_manager.render_cassandra_config(
131+
cluster_name=self.charm.config.cluster_name,
132+
listen_address=self.state.unit.ip,
133+
seeds=self.state.cluster.seeds,
134+
authentication=True,
135+
)
136+
self.workload.restart()
137+
self.state.unit.workload_state = UnitWorkloadState.STARTING
138+
87139
def _on_config_changed(self, _: ConfigChangedEvent) -> None:
140+
if self.state.unit.workload_state not in [
141+
UnitWorkloadState.STARTING,
142+
UnitWorkloadState.ACTIVE,
143+
]:
144+
return
88145
try:
89146
self.config_manager.render_env(
90147
cassandra_limit_memory_mb=1024 if self.charm.config.profile == "testing" else None
@@ -93,14 +150,14 @@ def _on_config_changed(self, _: ConfigChangedEvent) -> None:
93150
cluster_name=self.charm.config.cluster_name,
94151
listen_address=self.state.unit.ip,
95152
seeds=self.state.cluster.seeds,
153+
authentication=True,
96154
)
97155
except ValidationError as e:
98156
logger.debug(f"Config haven't passed validation: {e}")
99157
return
100-
if not self.state.unit.workload_state == UnitWorkloadState.ACTIVE:
101-
return
102158
self.workload.restart()
103-
self.state.unit.workload_state = UnitWorkloadState.STARTING
159+
if self.state.unit.workload_state == UnitWorkloadState.ACTIVE:
160+
self.state.unit.workload_state = UnitWorkloadState.STARTING
104161

105162
def _on_update_status(self, _: UpdateStatusEvent) -> None:
106163
if (
@@ -141,6 +198,9 @@ def _on_collect_unit_status(self, event: CollectStatusEvent) -> None:
141198
] and (self.charm.unit.is_leader() or self.state.cluster.is_active):
142199
event.add_status(Status.STARTING.value)
143200

201+
if self.state.unit.workload_state == UnitWorkloadState.CHANGING_PASSWORD:
202+
event.add_status(Status.CHANGING_PASSWORD.value)
203+
144204
event.add_status(Status.ACTIVE.value)
145205

146206
def _on_collect_app_status(self, event: CollectStatusEvent) -> None:
@@ -165,3 +225,11 @@ def _update_network_address(self) -> bool:
165225
and old_hostname is not None
166226
and (old_ip != self.state.unit.ip or old_hostname != self.state.unit.hostname)
167227
)
228+
229+
@property
230+
def _cassandra(self) -> CassandraClient:
231+
return CassandraClient(
232+
[self.state.unit.ip if self.state.cluster.cassandra_password_secret else "127.0.0.1"],
233+
"cassandra",
234+
self.state.cluster.cassandra_password_secret or None,
235+
)

0 commit comments

Comments
 (0)