Skip to content

Commit 3300905

Browse files
committed
Initial authentication work.
1 parent b4105ed commit 3300905

File tree

10 files changed

+212
-67
lines changed

10 files changed

+212
-67
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/charm.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def __init__(self, *args):
3838
cluster_name=self.state.cluster.cluster_name,
3939
listen_address=self.state.unit.ip,
4040
seeds=self.state.cluster.seeds,
41+
authentication=bool(self.state.cluster.cassandra_password_secret),
4142
)
4243
bootstrap_manager = RollingOpsManager(
4344
charm=self, relation="bootstrap", callback=self.bootstrap

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"
@@ -185,6 +187,16 @@ def is_active(self) -> bool:
185187
"""Whether Cassandra cluster state is `ACTIVE`."""
186188
return self.state == ClusterState.ACTIVE
187189

190+
@property
191+
def cassandra_password_secret(self) -> str:
192+
"""TODO."""
193+
return self.relation_data.get("cassandra-password", "")
194+
195+
@cassandra_password_secret.setter
196+
def cassandra_password_secret(self, value: str) -> None:
197+
"""TODO."""
198+
self._field_setter_wrapper("cassandra-password", value)
199+
188200

189201
class ApplicationState(Object):
190202
"""Mappings for the charm relations that forms global application state."""
@@ -194,6 +206,7 @@ def __init__(self, charm: CharmBase):
194206
self.peer_app_interface = DataPeerData(
195207
self.model,
196208
relation_name=PEER_RELATION,
209+
additional_secret_fields=["cassandra-password"],
197210
)
198211
self.peer_unit_interface = DataPeerUnitData(self.model, relation_name=PEER_RELATION)
199212

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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
)
1919
from pydantic import ValidationError
2020

21+
from common.cassandra_client import CassandraClient
2122
from core.config import CharmConfig
2223
from core.state import ApplicationState, UnitWorkloadState
2324
from core.statuses import Status
@@ -67,6 +68,10 @@ def _on_start(self, event: StartEvent) -> None:
6768
event.defer()
6869
return
6970

71+
if self.state.unit.workload_state == UnitWorkloadState.CHANGING_PASSWORD:
72+
self._finalize_password_change(event)
73+
return
74+
7075
try:
7176
if self.charm.unit.is_leader():
7277
self.state.cluster.cluster_name = self.charm.config.cluster_name
@@ -79,20 +84,57 @@ def _on_start(self, event: StartEvent) -> None:
7984
event.defer()
8085
return
8186

87+
if not self.state.cluster.cassandra_password_secret:
88+
self._start_password_change(event)
89+
return
90+
8291
self.config_manager.render_cassandra_config(
8392
cluster_name=self.state.cluster.cluster_name,
8493
listen_address=self.state.unit.ip,
8594
seeds=self.state.cluster.seeds,
95+
authentication=True,
96+
)
97+
self.charm.on[str(self.bootstrap_manager.name)].acquire_lock.emit()
98+
99+
def _start_password_change(self, event: StartEvent) -> None:
100+
self.config_manager.render_cassandra_config(
101+
cluster_name=self.charm.config.cluster_name,
102+
listen_address="127.0.0.1",
103+
seeds=["127.0.0.1:7000"],
104+
authentication=False,
86105
)
106+
self.workload.start()
107+
self.state.unit.workload_state = UnitWorkloadState.CHANGING_PASSWORD
108+
event.defer()
87109

110+
def _finalize_password_change(self, event: StartEvent) -> None:
111+
if not self.cluster_manager.is_healthy:
112+
event.defer()
113+
return
114+
password = self.workload.generate_password()
115+
self._cassandra.change_superuser_password("cassandra", password)
116+
self.state.cluster.cassandra_password_secret = password
117+
self.cluster_manager.flush_tables("system_auth", ["roles"])
118+
self.config_manager.render_cassandra_config(
119+
cluster_name=self.charm.config.cluster_name,
120+
listen_address=self.state.unit.ip,
121+
seeds=self.state.cluster.seeds,
122+
authentication=True,
123+
)
88124
self.charm.on[str(self.bootstrap_manager.name)].acquire_lock.emit()
89125

90126
def _on_config_changed(self, _: ConfigChangedEvent) -> None:
127+
if self.state.unit.workload_state not in [
128+
UnitWorkloadState.STARTING,
129+
UnitWorkloadState.ACTIVE,
130+
]:
131+
return
91132
try:
92133
# TODO: cluster_name change
93134
self.config_manager.render_env(
94135
cassandra_limit_memory_mb=1024 if self.charm.config.profile == "testing" else None
95136
)
137+
self.config_manager.render_cassandra_config()
96138
except ValidationError as e:
97139
logger.debug(f"Config haven't passed validation: {e}")
98140
return
@@ -136,6 +178,9 @@ def _on_collect_unit_status(self, event: CollectStatusEvent) -> None:
136178
] and (self.charm.unit.is_leader() or self.state.cluster.is_active):
137179
event.add_status(Status.STARTING.value)
138180

181+
if self.state.unit.workload_state == UnitWorkloadState.CHANGING_PASSWORD:
182+
event.add_status(Status.CHANGING_PASSWORD.value)
183+
139184
event.add_status(Status.ACTIVE.value)
140185

141186
def _on_collect_app_status(self, event: CollectStatusEvent) -> None:
@@ -160,3 +205,11 @@ def _update_network_address(self) -> bool:
160205
and old_hostname is not None
161206
and (old_ip != self.state.unit.ip or old_hostname != self.state.unit.hostname)
162207
)
208+
209+
@property
210+
def _cassandra(self) -> CassandraClient:
211+
return CassandraClient(
212+
[self.state.unit.ip if self.state.cluster.cassandra_password_secret else "127.0.0.1"],
213+
"cassandra",
214+
self.state.cluster.cassandra_password_secret or None,
215+
)

src/managers/cluster.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,7 @@ def network_address(self) -> tuple[str, str]:
3535
"""Get hostname and IP of this unit."""
3636
hostname = socket.gethostname()
3737
return socket.gethostbyname(hostname), hostname
38+
39+
def flush_tables(self, keyspace: str, tables: list[str]) -> None:
40+
"""TODO."""
41+
self._workload.exec(["charmed-cassandra.nodetool", "flush", keyspace, *tables])

0 commit comments

Comments
 (0)