Skip to content

Commit 8a3957e

Browse files
[DPE-6344] LDAP V: Define mapping option (#900)
1 parent 1be9f1b commit 8a3957e

File tree

6 files changed

+149
-16
lines changed

6 files changed

+149
-16
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_map:
73+
description: |
74+
List of mapped LDAP group names to PostgreSQL group names, separated by commas.
75+
The map is used to assign LDAP synchronized users to PostgreSQL authorization groups.
76+
Example: <ldap_group_1>=<psql_group_1>,<ldap_group_2>=<psql_group_2>
77+
type: string
7278
ldap_search_filter:
7379
description: |
7480
The LDAP search filter to match users with.

lib/charms/postgresql_k8s/v0/postgresql.py

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535

3636
# Increment this PATCH version before using `charmcraft publish-lib` or reset
3737
# to 0 if you are raising the major API version
38-
LIBPATCH = 47
38+
LIBPATCH = 48
3939

4040
# Groups to distinguish HBA access
4141
ACCESS_GROUP_IDENTITY = "identity_access"
@@ -773,6 +773,42 @@ def is_restart_pending(self) -> bool:
773773
if connection:
774774
connection.close()
775775

776+
@staticmethod
777+
def build_postgresql_group_map(group_map: Optional[str]) -> List[Tuple]:
778+
"""Build the PostgreSQL authorization group-map.
779+
780+
Args:
781+
group_map: serialized group-map with the following format:
782+
<ldap_group_1>=<psql_group_1>,
783+
<ldap_group_2>=<psql_group_2>,
784+
...
785+
786+
Returns:
787+
List of LDAP group to PostgreSQL group tuples.
788+
"""
789+
if group_map is None:
790+
return []
791+
792+
group_mappings = group_map.split(",")
793+
group_mappings = (mapping.strip() for mapping in group_mappings)
794+
group_map_list = []
795+
796+
for mapping in group_mappings:
797+
mapping_parts = mapping.split("=")
798+
if len(mapping_parts) != 2:
799+
raise ValueError("The group-map must contain value pairs split by commas")
800+
801+
ldap_group = mapping_parts[0]
802+
psql_group = mapping_parts[1]
803+
804+
if psql_group in [*ACCESS_GROUPS, PERMISSIONS_GROUP_ADMIN]:
805+
logger.warning(f"Tried to assign LDAP users to forbidden group: {psql_group}")
806+
continue
807+
808+
group_map_list.append((ldap_group, psql_group))
809+
810+
return group_map_list
811+
776812
@staticmethod
777813
def build_postgresql_parameters(
778814
config_options: dict, available_memory: int, limit_memory: Optional[int] = None
@@ -852,3 +888,34 @@ def validate_date_style(self, date_style: str) -> bool:
852888
return True
853889
except psycopg2.Error:
854890
return False
891+
892+
def validate_group_map(self, group_map: Optional[str]) -> bool:
893+
"""Validate the PostgreSQL authorization group-map.
894+
895+
Args:
896+
group_map: serialized group-map with the following format:
897+
<ldap_group_1>=<psql_group_1>,
898+
<ldap_group_2>=<psql_group_2>,
899+
...
900+
901+
Returns:
902+
Whether the group-map is valid.
903+
"""
904+
if group_map is None:
905+
return True
906+
907+
try:
908+
group_map = self.build_postgresql_group_map(group_map)
909+
except ValueError:
910+
return False
911+
912+
for _, psql_group in group_map:
913+
with self._connect_to_database() as connection, connection.cursor() as cursor:
914+
query = SQL("SELECT TRUE FROM pg_roles WHERE rolname={};")
915+
query = query.format(Literal(psql_group))
916+
cursor.execute(query)
917+
918+
if cursor.fetchone() is None:
919+
return False
920+
921+
return True

src/charm.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider
3737
from charms.loki_k8s.v1.loki_push_api import LogProxyConsumer
3838
from charms.postgresql_k8s.v0.postgresql import (
39+
ACCESS_GROUP_IDENTITY,
3940
ACCESS_GROUPS,
4041
REQUIRED_PLUGINS,
4142
PostgreSQL,
@@ -1760,6 +1761,7 @@ def _generate_ldap_service(self) -> dict:
17601761
ldap_base_dn = ldap_params["ldapbasedn"]
17611762
ldap_bind_username = ldap_params["ldapbinddn"]
17621763
ldap_bing_password = ldap_params["ldapbindpasswd"]
1764+
ldap_group_mappings = self.postgresql.build_postgresql_group_map(self.config.ldap_map)
17631765

17641766
return {
17651767
"override": "replace",
@@ -1772,6 +1774,8 @@ def _generate_ldap_service(self) -> dict:
17721774
"LDAP_BASE_DN": ldap_base_dn,
17731775
"LDAP_BIND_USERNAME": ldap_bind_username,
17741776
"LDAP_BIND_PASSWORD": ldap_bing_password,
1777+
"LDAP_GROUP_IDENTITY": json.dumps(ACCESS_GROUP_IDENTITY),
1778+
"LDAP_GROUP_MAPPINGS": json.dumps(ldap_group_mappings),
17751779
"POSTGRES_HOST": "127.0.0.1",
17761780
"POSTGRES_PORT": DATABASE_PORT,
17771781
"POSTGRES_DATABASE": DATABASE_DEFAULT_NAME,
@@ -2095,11 +2099,7 @@ def update_config(self, is_creating_backup: bool = False) -> bool:
20952099

20962100
self._handle_postgresql_restart_need()
20972101
self._restart_metrics_service()
2098-
2099-
# TODO: Un-comment
2100-
# When PostgreSQL-rock wrapping PostgreSQL-snap versions 162 / 163 gets published
2101-
# (i.e. snap contains https://github.com/canonical/charmed-postgresql-snap/pull/88)
2102-
# self._restart_ldap_sync_service()
2102+
self._restart_ldap_sync_service()
21032103

21042104
return True
21052105

@@ -2113,6 +2113,9 @@ def _validate_config_options(self) -> None:
21132113
"instance_default_text_search_config config option has an invalid value"
21142114
)
21152115

2116+
if not self.postgresql.validate_group_map(self.config.ldap_map):
2117+
raise ValueError("ldap_map config option has an invalid value")
2118+
21162119
if not self.postgresql.validate_date_style(self.config.request_date_style):
21172120
raise ValueError("request_date_style config option has an invalid value")
21182121

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_map: str | None
3031
ldap_search_filter: str | None
3132
logging_client_min_messages: str | None
3233
logging_log_connections: bool | None

tests/unit/test_charm.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,6 +1107,7 @@ def test_validate_config_options(harness):
11071107
harness.set_can_connect(POSTGRESQL_CONTAINER, True)
11081108
_charm_lib.return_value.get_postgresql_text_search_configs.return_value = []
11091109
_charm_lib.return_value.validate_date_style.return_value = []
1110+
_charm_lib.return_value.validate_group_map.return_value = False
11101111
_charm_lib.return_value.get_postgresql_timezones.return_value = []
11111112

11121113
# Test instance_default_text_search_config exception
@@ -1124,6 +1125,17 @@ def test_validate_config_options(harness):
11241125
"pg_catalog.test"
11251126
]
11261127

1128+
# Test ldap_map exception
1129+
with harness.hooks_disabled():
1130+
harness.update_config({"ldap_map": "ldap_group="})
1131+
1132+
with tc.assertRaises(ValueError) as e:
1133+
harness.charm._validate_config_options()
1134+
assert e.msg == "ldap_map config option has an invalid value"
1135+
1136+
_charm_lib.return_value.validate_group_map.assert_called_once_with("ldap_group=")
1137+
_charm_lib.return_value.validate_group_map.return_value = True
1138+
11271139
# Test request_date_style exception
11281140
with harness.hooks_disabled():
11291141
harness.update_config({"request_date_style": "ISO, TEST"})
@@ -1146,10 +1158,6 @@ def test_validate_config_options(harness):
11461158
_charm_lib.return_value.get_postgresql_timezones.assert_called_once_with()
11471159
_charm_lib.return_value.get_postgresql_timezones.return_value = ["TEST_ZONE"]
11481160

1149-
#
1150-
# Secrets
1151-
#
1152-
11531161

11541162
def test_scope_obj(harness):
11551163
assert harness.charm._scope_obj("app") == harness.charm.framework.model.app
@@ -1711,13 +1719,13 @@ def test_update_config(harness):
17111719
)
17121720
_handle_postgresql_restart_need.assert_called_once()
17131721
_restart_metrics_service.assert_called_once()
1714-
# _restart_ldap_sync_service.assert_called_once()
1722+
_restart_ldap_sync_service.assert_called_once()
17151723
assert "tls" not in harness.get_relation_data(rel_id, harness.charm.unit.name)
17161724

17171725
# Test with TLS files available.
17181726
_handle_postgresql_restart_need.reset_mock()
17191727
_restart_metrics_service.reset_mock()
1720-
# _restart_ldap_sync_service.reset_mock()
1728+
_restart_ldap_sync_service.reset_mock()
17211729
harness.update_relation_data(
17221730
rel_id, harness.charm.unit.name, {"tls": ""}
17231731
) # Mock some data in the relation to test that it change.
@@ -1740,7 +1748,7 @@ def test_update_config(harness):
17401748
)
17411749
_handle_postgresql_restart_need.assert_called_once()
17421750
_restart_metrics_service.assert_called_once()
1743-
# _restart_ldap_sync_service.assert_called_once()
1751+
_restart_ldap_sync_service.assert_called_once()
17441752
assert "tls" not in harness.get_relation_data(
17451753
rel_id, harness.charm.unit.name
17461754
) # The "tls" flag is set in handle_postgresql_restart_need.
@@ -1751,11 +1759,11 @@ def test_update_config(harness):
17511759
) # Mock some data in the relation to test that it change.
17521760
_handle_postgresql_restart_need.reset_mock()
17531761
_restart_metrics_service.reset_mock()
1754-
# _restart_ldap_sync_service.reset_mock()
1762+
_restart_ldap_sync_service.reset_mock()
17551763
harness.charm.update_config()
17561764
_handle_postgresql_restart_need.assert_not_called()
17571765
_restart_metrics_service.assert_not_called()
1758-
# _restart_ldap_sync_service.assert_not_called()
1766+
_restart_ldap_sync_service.assert_not_called()
17591767
assert harness.get_relation_data(rel_id, harness.charm.unit.name)["tls"] == "enabled"
17601768

17611769
# Test with member not started yet.
@@ -1765,7 +1773,7 @@ def test_update_config(harness):
17651773
harness.charm.update_config()
17661774
_handle_postgresql_restart_need.assert_not_called()
17671775
_restart_metrics_service.assert_not_called()
1768-
# _restart_ldap_sync_service.assert_not_called()
1776+
_restart_ldap_sync_service.assert_not_called()
17691777
assert "tls" not in harness.get_relation_data(rel_id, harness.charm.unit.name)
17701778

17711779

tests/unit/test_postgresql.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,27 @@ def test_get_last_archived_wal(harness):
370370
execute.assert_called_once_with("SELECT last_archived_wal FROM pg_stat_archiver;")
371371

372372

373+
def test_build_postgresql_group_map(harness):
374+
assert harness.charm.postgresql.build_postgresql_group_map(None) == []
375+
assert harness.charm.postgresql.build_postgresql_group_map("ldap_group=admin") == []
376+
377+
for group in ACCESS_GROUPS:
378+
assert harness.charm.postgresql.build_postgresql_group_map(f"ldap_group={group}") == []
379+
380+
mapping_1 = "ldap_group_1=psql_group_1"
381+
mapping_2 = "ldap_group_2=psql_group_2"
382+
383+
assert harness.charm.postgresql.build_postgresql_group_map(f"{mapping_1},{mapping_2}") == [
384+
("ldap_group_1", "psql_group_1"),
385+
("ldap_group_2", "psql_group_2"),
386+
]
387+
try:
388+
harness.charm.postgresql.build_postgresql_group_map(f"{mapping_1} {mapping_2}")
389+
assert False
390+
except ValueError:
391+
assert True
392+
393+
373394
def test_build_postgresql_parameters(harness):
374395
# Test when not limit is imposed to the available memory.
375396
config_options = {
@@ -463,3 +484,30 @@ def test_configure_pgaudit(harness):
463484
call("ALTER SYSTEM RESET pgaudit.log_parameter;"),
464485
call("SELECT pg_reload_conf();"),
465486
])
487+
488+
489+
def test_validate_group_map(harness):
490+
with patch(
491+
"charms.postgresql_k8s.v0.postgresql.PostgreSQL._connect_to_database"
492+
) as _connect_to_database:
493+
execute = _connect_to_database.return_value.__enter__.return_value.cursor.return_value.__enter__.return_value.execute
494+
_connect_to_database.return_value.__enter__.return_value.cursor.return_value.__enter__.return_value.fetchone.return_value = None
495+
496+
query = SQL("SELECT TRUE FROM pg_roles WHERE rolname={};")
497+
498+
assert harness.charm.postgresql.validate_group_map(None) is True
499+
500+
assert harness.charm.postgresql.validate_group_map("") is False
501+
assert harness.charm.postgresql.validate_group_map("ldap_group=") is False
502+
execute.assert_has_calls([
503+
call(query.format(Literal(""))),
504+
])
505+
506+
assert harness.charm.postgresql.validate_group_map("ldap_group=admin") is True
507+
assert harness.charm.postgresql.validate_group_map("ldap_group=admin,") is False
508+
assert harness.charm.postgresql.validate_group_map("ldap_group admin") is False
509+
510+
assert harness.charm.postgresql.validate_group_map("ldap_group=missing_group") is False
511+
execute.assert_has_calls([
512+
call(query.format(Literal("missing_group"))),
513+
])

0 commit comments

Comments
 (0)