Skip to content

Commit 9faaf2c

Browse files
committed
Merge branch 'main' into sync-main-16
2 parents 3413b60 + b5b1494 commit 9faaf2c

File tree

11 files changed

+431
-5
lines changed

11 files changed

+431
-5
lines changed

config.yaml

Lines changed: 6 additions & 0 deletions
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.

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,
@@ -154,6 +155,7 @@ class CannotConnectError(Exception):
154155
PostgreSQL,
155156
PostgreSQLAsyncReplication,
156157
PostgreSQLBackups,
158+
PostgreSQLLDAP,
157159
PostgreSQLProvider,
158160
PostgreSQLTLS,
159161
PostgreSQLUpgrade,
@@ -228,6 +230,7 @@ def __init__(self, *args):
228230
self.framework.observe(self.on.upgrade_charm, self._on_upgrade_charm)
229231
self.postgresql_client_relation = PostgreSQLProvider(self)
230232
self.backup = PostgreSQLBackups(self, "s3-parameters")
233+
self.ldap = PostgreSQLLDAP(self, "ldap")
231234
self.tls = PostgreSQLTLS(self, PEER, [self.primary_endpoint, self.replicas_endpoint])
232235
self.async_replication = PostgreSQLAsyncReplication(self)
233236
self.restart_manager = RollingOpsManager(
@@ -1201,25 +1204,32 @@ def _on_get_password(self, event: ActionEvent) -> None:
12011204
If no user is provided, the password of the operator user is returned.
12021205
"""
12031206
username = event.params.get("username", USER)
1207+
if username not in PASSWORD_USERS and self.is_ldap_enabled:
1208+
event.fail("The action can be run only for system users when LDAP is enabled")
1209+
return
12041210
if username not in PASSWORD_USERS:
12051211
event.fail(
1206-
f"The action can be run only for users used by the charm or Patroni:"
1212+
f"The action can be run only for system users or Patroni:"
12071213
f" {', '.join(PASSWORD_USERS)} not {username}"
12081214
)
12091215
return
1216+
12101217
event.set_results({"password": self.get_secret(APP_SCOPE, f"{username}-password")})
12111218

1212-
def _on_set_password(self, event: ActionEvent) -> None:
1219+
def _on_set_password(self, event: ActionEvent) -> None: # noqa: C901
12131220
"""Set the password for the specified user."""
12141221
# Only leader can write the new password into peer relation.
12151222
if not self.unit.is_leader():
12161223
event.fail("The action can be run only on leader unit")
12171224
return
12181225

12191226
username = event.params.get("username", USER)
1227+
if username not in SYSTEM_USERS and self.is_ldap_enabled:
1228+
event.fail("The action can be run only for system users when LDAP is enabled")
1229+
return
12201230
if username not in SYSTEM_USERS:
12211231
event.fail(
1222-
f"The action can be run only for users used by the charm:"
1232+
f"The action can be run only for system users:"
12231233
f" {', '.join(SYSTEM_USERS)} not {username}"
12241234
)
12251235
return
@@ -1615,6 +1625,21 @@ def _patroni(self):
16151625
self.get_secret(APP_SCOPE, PATRONI_PASSWORD_KEY),
16161626
)
16171627

1628+
@property
1629+
def is_connectivity_enabled(self) -> bool:
1630+
"""Return whether this unit can be connected externally."""
1631+
return self.unit_peer_data.get("connectivity", "on") == "on"
1632+
1633+
@property
1634+
def is_ldap_charm_related(self) -> bool:
1635+
"""Return whether this unit has an LDAP charm related."""
1636+
return self.app_peer_data.get("ldap_enabled", "False") == "True"
1637+
1638+
@property
1639+
def is_ldap_enabled(self) -> bool:
1640+
"""Return whether this unit has LDAP enabled."""
1641+
return self.is_ldap_charm_related and self.is_cluster_initialised
1642+
16181643
@property
16191644
def is_primary(self) -> bool:
16201645
"""Return whether this unit is the primary instance."""
@@ -1907,8 +1932,9 @@ def update_config(self, is_creating_backup: bool = False) -> bool:
19071932
logger.info("Updating Patroni config file")
19081933
# Update and reload configuration based on TLS files availability.
19091934
self._patroni.render_patroni_yml_file(
1910-
connectivity=self.unit_peer_data.get("connectivity", "on") == "on",
1935+
connectivity=self.is_connectivity_enabled,
19111936
is_creating_backup=is_creating_backup,
1937+
enable_ldap=self.is_ldap_enabled,
19121938
enable_tls=self.is_tls_enabled,
19131939
is_no_sync_member=self.upgrade.is_no_sync_member,
19141940
backup_id=self.app_peer_data.get("restoring-backup"),
@@ -2310,6 +2336,35 @@ def get_plugins(self) -> list[str]:
23102336
plugins.append(ext)
23112337
return plugins
23122338

2339+
def get_ldap_parameters(self) -> dict:
2340+
"""Returns the LDAP configuration to use."""
2341+
if not self.is_cluster_initialised:
2342+
return {}
2343+
if not self.is_ldap_charm_related:
2344+
logger.debug("LDAP is not enabled")
2345+
return {}
2346+
2347+
relation_data = self.ldap.get_relation_data()
2348+
if relation_data is None:
2349+
return {}
2350+
2351+
params = {
2352+
"ldapbasedn": relation_data.base_dn,
2353+
"ldapbinddn": relation_data.bind_dn,
2354+
"ldapbindpasswd": relation_data.bind_password,
2355+
"ldaptls": relation_data.starttls,
2356+
"ldapurl": relation_data.urls[0],
2357+
}
2358+
2359+
# LDAP authentication parameters that are exclusive to
2360+
# one of the two supported modes (simple bind or search+bind)
2361+
# must be put at the very end of the parameters string
2362+
params.update({
2363+
"ldapsearchfilter": self.config.ldap_search_filter,
2364+
})
2365+
2366+
return params
2367+
23132368

23142369
if __name__ == "__main__":
23152370
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)