Skip to content

Commit 13b8279

Browse files
[DPE-6345] LDAP III: Define config and handlers (#825)
1 parent 3c2c894 commit 13b8279

File tree

9 files changed

+285
-7
lines changed

9 files changed

+285
-7
lines changed

config.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ options:
6969
Enable synchronized sequential scans.
7070
type: boolean
7171
default: true
72+
ldap_search_filter:
73+
description: |
74+
The LDAP search filter to match users with.
75+
Example: (|(uid=$username)(email=$username))
76+
type: string
77+
default: "(uid=$username)"
7278
logging_client_min_messages:
7379
description: |
7480
Sets the message levels that are sent to the client.
@@ -889,4 +895,4 @@ options:
889895
Multixact age at which VACUUM should scan whole table to freeze tuples.
890896
Allowed values are: from 0 to 2000000000.
891897
type: int
892-
default: 150000000
898+
default: 150000000

src/charm.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
USER,
101101
USER_PASSWORD_KEY,
102102
)
103+
from ldap import PostgreSQLLDAP
103104
from relations.async_replication import (
104105
REPLICATION_CONSUMER_RELATION,
105106
REPLICATION_OFFER_RELATION,
@@ -135,6 +136,7 @@ class CannotConnectError(Exception):
135136
PostgreSQL,
136137
PostgreSQLAsyncReplication,
137138
PostgreSQLBackups,
139+
PostgreSQLLDAP,
138140
PostgreSQLProvider,
139141
PostgreSQLTLS,
140142
PostgreSQLUpgrade,
@@ -206,6 +208,7 @@ def __init__(self, *args):
206208
self.legacy_db_relation = DbProvides(self, admin=False)
207209
self.legacy_db_admin_relation = DbProvides(self, admin=True)
208210
self.backup = PostgreSQLBackups(self, "s3-parameters")
211+
self.ldap = PostgreSQLLDAP(self, "ldap")
209212
self.tls = PostgreSQLTLS(self, PEER)
210213
self.async_replication = PostgreSQLAsyncReplication(self)
211214
self.restart_manager = RollingOpsManager(
@@ -909,6 +912,21 @@ def _patroni(self) -> Patroni:
909912
self.get_secret(APP_SCOPE, PATRONI_PASSWORD_KEY),
910913
)
911914

915+
@property
916+
def is_connectivity_enabled(self) -> bool:
917+
"""Return whether this unit can be connected externally."""
918+
return self.unit_peer_data.get("connectivity", "on") == "on"
919+
920+
@property
921+
def is_ldap_charm_related(self) -> bool:
922+
"""Return whether this unit has an LDAP charm related."""
923+
return self.app_peer_data.get("ldap_enabled", "False") == "True"
924+
925+
@property
926+
def is_ldap_enabled(self) -> bool:
927+
"""Return whether this unit has LDAP enabled."""
928+
return self.is_ldap_charm_related and self.is_cluster_initialised
929+
912930
@property
913931
def is_primary(self) -> bool:
914932
"""Return whether this unit is the primary instance."""
@@ -1407,12 +1425,16 @@ def _on_get_password(self, event: ActionEvent) -> None:
14071425
If no user is provided, the password of the operator user is returned.
14081426
"""
14091427
username = event.params.get("username", USER)
1428+
if username not in PASSWORD_USERS and self.is_ldap_enabled:
1429+
event.fail("The action can be run only for system users when LDAP is enabled")
1430+
return
14101431
if username not in PASSWORD_USERS:
14111432
event.fail(
1412-
f"The action can be run only for users used by the charm or Patroni:"
1433+
f"The action can be run only for system users or Patroni:"
14131434
f" {', '.join(PASSWORD_USERS)} not {username}"
14141435
)
14151436
return
1437+
14161438
event.set_results({"password": self.get_secret(APP_SCOPE, f"{username}-password")})
14171439

14181440
def _on_set_password(self, event: ActionEvent) -> None:
@@ -1423,9 +1445,12 @@ def _on_set_password(self, event: ActionEvent) -> None:
14231445
return
14241446

14251447
username = event.params.get("username", USER)
1448+
if username not in SYSTEM_USERS and self.is_ldap_enabled:
1449+
event.fail("The action can be run only for system users when LDAP is enabled")
1450+
return
14261451
if username not in SYSTEM_USERS:
14271452
event.fail(
1428-
f"The action can be run only for users used by the charm:"
1453+
f"The action can be run only for system users:"
14291454
f" {', '.join(SYSTEM_USERS)} not {username}"
14301455
)
14311456
return
@@ -1911,8 +1936,9 @@ def update_config(self, is_creating_backup: bool = False, no_peers: bool = False
19111936

19121937
# Update and reload configuration based on TLS files availability.
19131938
self._patroni.render_patroni_yml_file(
1914-
connectivity=self.unit_peer_data.get("connectivity", "on") == "on",
1939+
connectivity=self.is_connectivity_enabled,
19151940
is_creating_backup=is_creating_backup,
1941+
enable_ldap=self.is_ldap_enabled,
19161942
enable_tls=enable_tls,
19171943
backup_id=self.app_peer_data.get("restoring-backup"),
19181944
pitr_target=self.app_peer_data.get("restore-to-time"),
@@ -2177,6 +2203,35 @@ def get_plugins(self) -> list[str]:
21772203
plugins.append(ext)
21782204
return plugins
21792205

2206+
def get_ldap_parameters(self) -> dict:
2207+
"""Returns the LDAP configuration to use."""
2208+
if not self.is_cluster_initialised:
2209+
return {}
2210+
if not self.is_ldap_charm_related:
2211+
logger.debug("LDAP is not enabled")
2212+
return {}
2213+
2214+
data = self.ldap.get_relation_data()
2215+
if data is None:
2216+
return {}
2217+
2218+
params = {
2219+
"ldapbasedn": data.base_dn,
2220+
"ldapbinddn": data.bind_dn,
2221+
"ldapbindpasswd": data.bind_password,
2222+
"ldaptls": data.starttls,
2223+
"ldapurl": data.urls[0],
2224+
}
2225+
2226+
# LDAP authentication parameters that are exclusive to
2227+
# one of the two supported modes (simple bind or search+bind)
2228+
# must be put at the very end of the parameters string
2229+
params.update({
2230+
"ldapsearchfilter": self.config.ldap_search_filter,
2231+
})
2232+
2233+
return params
2234+
21802235

21812236
if __name__ == "__main__":
21822237
main(PostgresqlOperatorCharm)

src/cluster.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,17 @@ def _patroni_url(self) -> str:
162162
"""Patroni REST API URL."""
163163
return f"{'https' if self.tls_enabled else 'http'}://{self.unit_ip}:8008"
164164

165+
@staticmethod
166+
def _dict_to_hba_string(_dict: dict[str, Any]) -> str:
167+
"""Transform a dictionary into a Host Based Authentication valid string."""
168+
for key, value in _dict.items():
169+
if isinstance(value, bool):
170+
_dict[key] = int(value)
171+
if isinstance(value, str):
172+
_dict[key] = f'"{value}"'
173+
174+
return " ".join(f"{key}={value}" for key, value in _dict.items())
175+
165176
def bootstrap_cluster(self) -> bool:
166177
"""Bootstrap a PostgreSQL cluster using Patroni."""
167178
# Render the configuration files and start the cluster.
@@ -610,6 +621,7 @@ def render_patroni_yml_file(
610621
self,
611622
connectivity: bool = False,
612623
is_creating_backup: bool = False,
624+
enable_ldap: bool = False,
613625
enable_tls: bool = False,
614626
stanza: str | None = None,
615627
restore_stanza: str | None = None,
@@ -626,6 +638,7 @@ def render_patroni_yml_file(
626638
Args:
627639
connectivity: whether to allow external connections to the database.
628640
is_creating_backup: whether this unit is creating a backup.
641+
enable_ldap: whether to enable LDAP authentication.
629642
enable_tls: whether to enable TLS.
630643
stanza: name of the stanza created by pgBackRest.
631644
restore_stanza: name of the stanza used when restoring a backup.
@@ -640,6 +653,9 @@ def render_patroni_yml_file(
640653
# Open the template patroni.yml file.
641654
with open("templates/patroni.yml.j2") as file:
642655
template = Template(file.read())
656+
657+
ldap_params = self.charm.get_ldap_parameters()
658+
643659
# Render the template file with the correct values.
644660
rendered = template.render(
645661
conf_path=PATRONI_CONF_PATH,
@@ -648,6 +664,7 @@ def render_patroni_yml_file(
648664
log_path=PATRONI_LOGS_PATH,
649665
postgresql_log_path=POSTGRESQL_LOGS_PATH,
650666
data_path=POSTGRESQL_DATA_PATH,
667+
enable_ldap=enable_ldap,
651668
enable_tls=enable_tls,
652669
member_name=self.member_name,
653670
partner_addrs=self.charm.async_replication.get_partner_addresses()
@@ -677,6 +694,7 @@ def render_patroni_yml_file(
677694
primary_cluster_endpoint=self.charm.async_replication.get_primary_cluster_endpoint(),
678695
extra_replication_endpoints=self.charm.async_replication.get_standby_endpoints(),
679696
raft_password=self.raft_password,
697+
ldap_parameters=self._dict_to_hba_string(ldap_params),
680698
patroni_password=self.patroni_password,
681699
)
682700
self.render_file(f"{PATRONI_CONF_PATH}/patroni.yaml", rendered, 0o600)

src/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class CharmConfig(BaseConfigModel):
2929
instance_max_locks_per_transaction: int | None
3030
instance_password_encryption: str | None
3131
instance_synchronize_seqscans: bool | None
32+
ldap_search_filter: str | None
3233
logging_client_min_messages: str | None
3334
logging_log_connections: bool | None
3435
logging_log_disconnections: bool | None

src/ldap.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright 2025 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
4+
"""LDAP implementation."""
5+
6+
import logging
7+
8+
from charms.glauth_k8s.v0.ldap import (
9+
LdapProviderData,
10+
LdapReadyEvent,
11+
LdapRequirer,
12+
LdapUnavailableEvent,
13+
)
14+
from ops import Relation
15+
from ops.framework import Object
16+
from ops.model import ActiveStatus
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
class PostgreSQLLDAP(Object):
22+
"""In this class, we manage PostgreSQL LDAP access."""
23+
24+
def __init__(self, charm, relation_name: str):
25+
"""Manager of PostgreSQL LDAP."""
26+
super().__init__(charm, "ldap")
27+
self.charm = charm
28+
self.relation_name = relation_name
29+
30+
# LDAP relation handles the config options for LDAP access
31+
self.ldap = LdapRequirer(self.charm, self.relation_name)
32+
self.framework.observe(self.ldap.on.ldap_ready, self._on_ldap_ready)
33+
self.framework.observe(self.ldap.on.ldap_unavailable, self._on_ldap_unavailable)
34+
35+
@property
36+
def _relation(self) -> Relation:
37+
"""Return the relation object."""
38+
return self.model.get_relation(self.relation_name)
39+
40+
def _on_ldap_ready(self, _: LdapReadyEvent) -> None:
41+
"""Handler for the LDAP ready event."""
42+
logger.debug("Enabling LDAP connection")
43+
if self.charm.unit.is_leader():
44+
self.charm.app_peer_data.update({"ldap_enabled": "True"})
45+
46+
self.charm.update_config()
47+
self.charm.unit.status = ActiveStatus()
48+
49+
def _on_ldap_unavailable(self, _: LdapUnavailableEvent) -> None:
50+
"""Handler for the LDAP unavailable event."""
51+
logger.debug("Disabling LDAP connection")
52+
if self.charm.unit.is_leader():
53+
self.charm.app_peer_data.update({"ldap_enabled": "False"})
54+
55+
self.charm.update_config()
56+
57+
def get_relation_data(self) -> LdapProviderData | None:
58+
"""Get the LDAP info from the LDAP Provider class."""
59+
data = self.ldap.consume_ldap_relation_data(relation=self._relation)
60+
if data is None:
61+
logger.warning("LDAP relation is not ready")
62+
63+
if not self.charm.is_connectivity_enabled:
64+
logger.warning("LDAP server will not be accessible")
65+
66+
return data

templates/patroni.yml.j2

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,14 @@ postgresql:
161161
{%- if not connectivity %}
162162
- {{ 'hostssl' if enable_tls else 'host' }} all all 0.0.0.0/0 reject
163163
- {{ 'hostssl' if enable_tls else 'host' }} all all {{ self_ip }} md5
164-
{% else %}
165-
- {{ 'hostssl' if enable_tls else 'host' }} replication replication 127.0.0.1/32 md5
166-
{%- endif %}
164+
{%- elif enable_ldap %}
165+
- {{ 'hostssl' if enable_tls else 'host' }} all +identity_access 0.0.0.0/0 ldap {{ ldap_parameters }}
166+
- {{ 'hostssl' if enable_tls else 'host' }} all +internal_access 0.0.0.0/0 md5
167+
- {{ 'hostssl' if enable_tls else 'host' }} all +relation_access 0.0.0.0/0 md5
168+
{%- else %}
167169
- {{ 'hostssl' if enable_tls else 'host' }} all all 0.0.0.0/0 md5
170+
{%- endif %}
171+
- {{ 'hostssl' if enable_tls else 'host' }} replication replication 127.0.0.1/32 md5
168172
# Allow replications connections from other cluster members.
169173
{%- for endpoint in extra_replication_endpoints %}
170174
- {{ 'hostssl' if enable_tls else 'host' }} replication replication {{ endpoint }}/32 md5

tests/unit/test_charm.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,7 @@ def test_update_config(harness):
13011301
_render_patroni_yml_file.assert_called_once_with(
13021302
connectivity=True,
13031303
is_creating_backup=False,
1304+
enable_ldap=False,
13041305
enable_tls=False,
13051306
backup_id=None,
13061307
stanza=None,
@@ -1325,6 +1326,7 @@ def test_update_config(harness):
13251326
_render_patroni_yml_file.assert_called_once_with(
13261327
connectivity=True,
13271328
is_creating_backup=False,
1329+
enable_ldap=False,
13281330
enable_tls=True,
13291331
backup_id=None,
13301332
stanza=None,
@@ -2845,3 +2847,35 @@ def test_on_promote_to_primary(harness):
28452847
harness.charm._on_promote_to_primary(event)
28462848
_raft_reinitialisation.assert_called_once_with()
28472849
assert harness.charm.unit_peer_data["raft_candidate"] == "True"
2850+
2851+
2852+
def test_get_ldap_parameters(harness):
2853+
with (
2854+
patch("charm.PostgreSQLLDAP.get_relation_data") as _get_relation_data,
2855+
patch(
2856+
target="charm.PostgresqlOperatorCharm.is_cluster_initialised",
2857+
new_callable=PropertyMock,
2858+
return_value=True,
2859+
) as _cluster_initialised,
2860+
):
2861+
with harness.hooks_disabled():
2862+
harness.update_relation_data(
2863+
harness.model.get_relation(PEER).id,
2864+
harness.charm.app.name,
2865+
{"ldap_enabled": "False"},
2866+
)
2867+
2868+
harness.charm.get_ldap_parameters()
2869+
_get_relation_data.assert_not_called()
2870+
_get_relation_data.reset_mock()
2871+
2872+
with harness.hooks_disabled():
2873+
harness.update_relation_data(
2874+
harness.model.get_relation(PEER).id,
2875+
harness.charm.app.name,
2876+
{"ldap_enabled": "True"},
2877+
)
2878+
2879+
harness.charm.get_ldap_parameters()
2880+
_get_relation_data.assert_called_once()
2881+
_get_relation_data.reset_mock()

tests/unit/test_cluster.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,24 @@ def test_get_postgresql_version(peers_ips, patroni):
181181
_get_installed_snaps.assert_called_once_with()
182182

183183

184+
def test_dict_to_hba_string(harness, patroni):
185+
mock_data = {
186+
"ldapbasedn": "dc=example,dc=net",
187+
"ldapbinddn": "cn=serviceuser,dc=example,dc=net",
188+
"ldapbindpasswd": "password",
189+
"ldaptls": False,
190+
"ldapurl": "ldap://0.0.0.0:3893",
191+
}
192+
193+
assert patroni._dict_to_hba_string(mock_data) == (
194+
'ldapbasedn="dc=example,dc=net" '
195+
'ldapbinddn="cn=serviceuser,dc=example,dc=net" '
196+
'ldapbindpasswd="password" '
197+
"ldaptls=0 "
198+
'ldapurl="ldap://0.0.0.0:3893"'
199+
)
200+
201+
184202
def test_get_primary(peers_ips, patroni):
185203
with (
186204
patch("requests.get", side_effect=mocked_requests_get) as _get,

0 commit comments

Comments
 (0)