Skip to content

Commit b5b1494

Browse files
[DPE-6344] LDAP III: Define config and handlers (#886)
1 parent 5d98a0f commit b5b1494

File tree

11 files changed

+432
-6
lines changed

11 files changed

+432
-6
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: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@
114114
WORKLOAD_OS_GROUP,
115115
WORKLOAD_OS_USER,
116116
)
117+
from ldap import PostgreSQLLDAP
117118
from patroni import NotReadyError, Patroni, SwitchoverFailedError, SwitchoverNotSyncError
118119
from relations.async_replication import (
119120
REPLICATION_CONSUMER_RELATION,
@@ -156,6 +157,7 @@ class CannotConnectError(Exception):
156157
PostgreSQL,
157158
PostgreSQLAsyncReplication,
158159
PostgreSQLBackups,
160+
PostgreSQLLDAP,
159161
PostgreSQLProvider,
160162
PostgreSQLTLS,
161163
PostgreSQLUpgrade,
@@ -232,6 +234,7 @@ def __init__(self, *args):
232234
self.legacy_db_relation = DbProvides(self, admin=False)
233235
self.legacy_db_admin_relation = DbProvides(self, admin=True)
234236
self.backup = PostgreSQLBackups(self, "s3-parameters")
237+
self.ldap = PostgreSQLLDAP(self, "ldap")
235238
self.tls = PostgreSQLTLS(self, PEER, [self.primary_endpoint, self.replicas_endpoint])
236239
self.async_replication = PostgreSQLAsyncReplication(self)
237240
self.restart_manager = RollingOpsManager(
@@ -1234,25 +1237,32 @@ def _on_get_password(self, event: ActionEvent) -> None:
12341237
If no user is provided, the password of the operator user is returned.
12351238
"""
12361239
username = event.params.get("username", USER)
1240+
if username not in PASSWORD_USERS and self.is_ldap_enabled:
1241+
event.fail("The action can be run only for system users when LDAP is enabled")
1242+
return
12371243
if username not in PASSWORD_USERS:
12381244
event.fail(
1239-
f"The action can be run only for users used by the charm or Patroni:"
1245+
f"The action can be run only for system users or Patroni:"
12401246
f" {', '.join(PASSWORD_USERS)} not {username}"
12411247
)
12421248
return
1249+
12431250
event.set_results({"password": self.get_secret(APP_SCOPE, f"{username}-password")})
12441251

1245-
def _on_set_password(self, event: ActionEvent) -> None:
1252+
def _on_set_password(self, event: ActionEvent) -> None: # noqa: C901
12461253
"""Set the password for the specified user."""
12471254
# Only leader can write the new password into peer relation.
12481255
if not self.unit.is_leader():
12491256
event.fail("The action can be run only on leader unit")
12501257
return
12511258

12521259
username = event.params.get("username", USER)
1260+
if username not in SYSTEM_USERS and self.is_ldap_enabled:
1261+
event.fail("The action can be run only for system users when LDAP is enabled")
1262+
return
12531263
if username not in SYSTEM_USERS:
12541264
event.fail(
1255-
f"The action can be run only for users used by the charm:"
1265+
f"The action can be run only for system users:"
12561266
f" {', '.join(SYSTEM_USERS)} not {username}"
12571267
)
12581268
return
@@ -1648,6 +1658,21 @@ def _patroni(self):
16481658
self.get_secret(APP_SCOPE, PATRONI_PASSWORD_KEY),
16491659
)
16501660

1661+
@property
1662+
def is_connectivity_enabled(self) -> bool:
1663+
"""Return whether this unit can be connected externally."""
1664+
return self.unit_peer_data.get("connectivity", "on") == "on"
1665+
1666+
@property
1667+
def is_ldap_charm_related(self) -> bool:
1668+
"""Return whether this unit has an LDAP charm related."""
1669+
return self.app_peer_data.get("ldap_enabled", "False") == "True"
1670+
1671+
@property
1672+
def is_ldap_enabled(self) -> bool:
1673+
"""Return whether this unit has LDAP enabled."""
1674+
return self.is_ldap_charm_related and self.is_cluster_initialised
1675+
16511676
@property
16521677
def is_primary(self) -> bool:
16531678
"""Return whether this unit is the primary instance."""
@@ -1940,8 +1965,9 @@ def update_config(self, is_creating_backup: bool = False) -> bool:
19401965
logger.info("Updating Patroni config file")
19411966
# Update and reload configuration based on TLS files availability.
19421967
self._patroni.render_patroni_yml_file(
1943-
connectivity=self.unit_peer_data.get("connectivity", "on") == "on",
1968+
connectivity=self.is_connectivity_enabled,
19441969
is_creating_backup=is_creating_backup,
1970+
enable_ldap=self.is_ldap_enabled,
19451971
enable_tls=self.is_tls_enabled,
19461972
is_no_sync_member=self.upgrade.is_no_sync_member,
19471973
backup_id=self.app_peer_data.get("restoring-backup"),
@@ -2335,6 +2361,35 @@ def get_plugins(self) -> list[str]:
23352361
plugins.append(ext)
23362362
return plugins
23372363

2364+
def get_ldap_parameters(self) -> dict:
2365+
"""Returns the LDAP configuration to use."""
2366+
if not self.is_cluster_initialised:
2367+
return {}
2368+
if not self.is_ldap_charm_related:
2369+
logger.debug("LDAP is not enabled")
2370+
return {}
2371+
2372+
relation_data = self.ldap.get_relation_data()
2373+
if relation_data is None:
2374+
return {}
2375+
2376+
params = {
2377+
"ldapbasedn": relation_data.base_dn,
2378+
"ldapbinddn": relation_data.bind_dn,
2379+
"ldapbindpasswd": relation_data.bind_password,
2380+
"ldaptls": relation_data.starttls,
2381+
"ldapurl": relation_data.urls[0],
2382+
}
2383+
2384+
# LDAP authentication parameters that are exclusive to
2385+
# one of the two supported modes (simple bind or search+bind)
2386+
# must be put at the very end of the parameters string
2387+
params.update({
2388+
"ldapsearchfilter": self.config.ldap_search_filter,
2389+
})
2390+
2391+
return params
2392+
23382393

23392394
if __name__ == "__main__":
23402395
main(PostgresqlOperatorCharm, use_juju_for_storage=True)

src/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class CharmConfig(BaseConfigModel):
2727
instance_max_locks_per_transaction: int | None
2828
instance_password_encryption: str | None
2929
instance_synchronize_seqscans: bool | None
30+
ldap_search_filter: str | None
3031
logging_client_min_messages: str | None
3132
logging_log_connections: bool | None
3233
logging_log_disconnections: bool | None

src/ldap.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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 charms.postgresql_k8s.v0.postgresql_tls import (
15+
TLS_TRANSFER_RELATION,
16+
)
17+
from ops import Relation
18+
from ops.framework import Object
19+
from ops.model import ActiveStatus, BlockedStatus
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
class PostgreSQLLDAP(Object):
25+
"""In this class, we manage PostgreSQL LDAP access."""
26+
27+
def __init__(self, charm, relation_name: str):
28+
"""Manager of PostgreSQL LDAP."""
29+
super().__init__(charm, "ldap")
30+
self.charm = charm
31+
self.relation_name = relation_name
32+
33+
# LDAP relation handles the config options for LDAP access
34+
self.ldap = LdapRequirer(self.charm, self.relation_name)
35+
self.framework.observe(self.ldap.on.ldap_ready, self._on_ldap_ready)
36+
self.framework.observe(self.ldap.on.ldap_unavailable, self._on_ldap_unavailable)
37+
38+
@property
39+
def ca_transferred(self) -> bool:
40+
"""Return whether the CA certificate has been transferred."""
41+
ca_transferred_relations = self.model.relations[TLS_TRANSFER_RELATION]
42+
43+
for relation in ca_transferred_relations:
44+
if relation.app.name == self._relation.app.name:
45+
return True
46+
47+
return False
48+
49+
@property
50+
def _relation(self) -> Relation:
51+
"""Return the relation object."""
52+
return self.model.get_relation(self.relation_name)
53+
54+
def _on_ldap_ready(self, event: LdapReadyEvent) -> None:
55+
"""Handler for the LDAP ready event."""
56+
if not self.ca_transferred:
57+
self.charm.unit.status = BlockedStatus("LDAP insecure. Send LDAP server certificate")
58+
event.defer()
59+
return
60+
61+
logger.debug("Enabling LDAP connection")
62+
if self.charm.unit.is_leader():
63+
self.charm.app_peer_data.update({"ldap_enabled": "True"})
64+
65+
self.charm.update_config()
66+
self.charm.unit.status = ActiveStatus()
67+
68+
def _on_ldap_unavailable(self, _: LdapUnavailableEvent) -> None:
69+
"""Handler for the LDAP unavailable event."""
70+
logger.debug("Disabling LDAP connection")
71+
if self.charm.unit.is_leader():
72+
self.charm.app_peer_data.update({"ldap_enabled": "False"})
73+
74+
self.charm.update_config()
75+
76+
def get_relation_data(self) -> LdapProviderData | None:
77+
"""Get the LDAP info from the LDAP Provider class."""
78+
data = self.ldap.consume_ldap_relation_data(relation=self._relation)
79+
if data is None:
80+
logger.warning("LDAP relation is not ready")
81+
82+
if not self.charm.is_connectivity_enabled:
83+
logger.warning("LDAP server will not be accessible")
84+
85+
return data

src/patroni.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,17 @@ def rock_postgresql_version(self) -> str | None:
114114
snap_meta = container.pull("/meta.charmed-postgresql/snap.yaml")
115115
return yaml.safe_load(snap_meta)["version"]
116116

117+
@staticmethod
118+
def _dict_to_hba_string(_dict: dict[str, Any]) -> str:
119+
"""Transform a dictionary into a Host Based Authentication valid string."""
120+
for key, value in _dict.items():
121+
if isinstance(value, bool):
122+
_dict[key] = int(value)
123+
if isinstance(value, str):
124+
_dict[key] = f'"{value}"'
125+
126+
return " ".join(f"{key}={value}" for key, value in _dict.items())
127+
117128
def _get_alternative_patroni_url(
118129
self, attempt: AttemptManager, alternative_endpoints: list[str] | None = None
119130
) -> str:
@@ -503,6 +514,7 @@ def render_patroni_yml_file(
503514
self,
504515
connectivity: bool = False,
505516
is_creating_backup: bool = False,
517+
enable_ldap: bool = False,
506518
enable_tls: bool = False,
507519
is_no_sync_member: bool = False,
508520
stanza: str | None = None,
@@ -518,6 +530,7 @@ def render_patroni_yml_file(
518530
519531
Args:
520532
connectivity: whether to allow external connections to the database.
533+
enable_ldap: whether to enable LDAP authentication.
521534
enable_tls: whether to enable TLS.
522535
is_creating_backup: whether this unit is creating a backup.
523536
is_no_sync_member: whether this member shouldn't be a synchronous standby
@@ -534,9 +547,13 @@ def render_patroni_yml_file(
534547
# Open the template patroni.yml file.
535548
with open("templates/patroni.yml.j2") as file:
536549
template = Template(file.read())
550+
551+
ldap_params = self._charm.get_ldap_parameters()
552+
537553
# Render the template file with the correct values.
538554
rendered = template.render(
539555
connectivity=connectivity,
556+
enable_ldap=enable_ldap,
540557
enable_tls=enable_tls,
541558
endpoint=self._endpoint,
542559
endpoints=self._endpoints,
@@ -562,6 +579,7 @@ def render_patroni_yml_file(
562579
pg_parameters=parameters,
563580
primary_cluster_endpoint=self._charm.async_replication.get_primary_cluster_endpoint(),
564581
extra_replication_endpoints=self._charm.async_replication.get_standby_endpoints(),
582+
ldap_parameters=self._dict_to_hba_string(ldap_params),
565583
patroni_password=self._patroni_password,
566584
)
567585
self._render_file(f"{self._storage_path}/patroni.yml", rendered, 0o644)

templates/patroni.yml.j2

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,11 @@ postgresql:
140140
{%- if not connectivity %}
141141
- {{ 'hostssl' if enable_tls else 'host' }} all all 0.0.0.0/0 reject
142142
- {{ 'hostssl' if enable_tls else 'host' }} all all {{ endpoint }}.{{ namespace }}.svc.cluster.local md5
143-
{% else %}
143+
{%- elif enable_ldap %}
144+
- {{ 'hostssl' if enable_tls else 'host' }} all +identity_access 0.0.0.0/0 ldap {{ ldap_parameters }}
145+
- {{ 'hostssl' if enable_tls else 'host' }} all +internal_access 0.0.0.0/0 md5
146+
- {{ 'hostssl' if enable_tls else 'host' }} all +relation_access 0.0.0.0/0 md5
147+
{%- else %}
144148
- {{ 'hostssl' if enable_tls else 'host' }} all all 0.0.0.0/0 md5
145149
{%- endif %}
146150
- {{ 'hostssl' if enable_tls else 'host' }} replication replication 127.0.0.1/32 md5

0 commit comments

Comments
 (0)