Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ options:
Enable synchronized sequential scans.
type: boolean
default: true
ldap_map:
description: |
List of mapped LDAP group names to PostgreSQL group names, separated by commas.
The map is used to assign LDAP synchronized users to PostgreSQL authorization groups.
Example: <ldap_group_1>=<psql_group_1>,<ldap_group_2>=<psql_group_2>
type: string
ldap_search_filter:
description: |
The LDAP search filter to match users with.
Expand Down
69 changes: 68 additions & 1 deletion lib/charms/postgresql_k8s/v0/postgresql.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@

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

# Groups to distinguish HBA access
ACCESS_GROUP_IDENTITY = "identity_access"
Expand Down Expand Up @@ -773,6 +773,42 @@ def is_restart_pending(self) -> bool:
if connection:
connection.close()

@staticmethod
def build_postgresql_group_map(group_map: Optional[str]) -> List[Tuple]:
"""Build the PostgreSQL authorization group-map.

Args:
group_map: serialized group-map with the following format:
<ldap_group_1>=<psql_group_1>,
<ldap_group_2>=<psql_group_2>,
...

Returns:
List of LDAP group to PostgreSQL group tuples.
"""
if group_map is None:
return []

group_mappings = group_map.split(",")
group_mappings = (mapping.strip() for mapping in group_mappings)
group_map_list = []

for mapping in group_mappings:
mapping_parts = mapping.split("=")
if len(mapping_parts) != 2:
raise ValueError("The group-map must contain value pairs split by commas")

ldap_group = mapping_parts[0]
psql_group = mapping_parts[1]

if psql_group in [*ACCESS_GROUPS, PERMISSIONS_GROUP_ADMIN]:
logger.warning(f"Tried to assign LDAP users to forbidden group: {psql_group}")
continue

group_map_list.append((ldap_group, psql_group))

return group_map_list

@staticmethod
def build_postgresql_parameters(
config_options: dict, available_memory: int, limit_memory: Optional[int] = None
Expand Down Expand Up @@ -852,3 +888,34 @@ def validate_date_style(self, date_style: str) -> bool:
return True
except psycopg2.Error:
return False

def validate_group_map(self, group_map: Optional[str]) -> bool:
"""Validate the PostgreSQL authorization group-map.

Args:
group_map: serialized group-map with the following format:
<ldap_group_1>=<psql_group_1>,
<ldap_group_2>=<psql_group_2>,
...

Returns:
Whether the group-map is valid.
"""
if group_map is None:
return True

try:
group_map = self.build_postgresql_group_map(group_map)
except ValueError:
return False

for _, psql_group in group_map:
with self._connect_to_database() as connection, connection.cursor() as cursor:
query = SQL("SELECT TRUE FROM pg_roles WHERE rolname={};")
query = query.format(Literal(psql_group))
cursor.execute(query)

if cursor.fetchone() is None:
return False

return True
13 changes: 8 additions & 5 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider
from charms.loki_k8s.v1.loki_push_api import LogProxyConsumer
from charms.postgresql_k8s.v0.postgresql import (
ACCESS_GROUP_IDENTITY,
ACCESS_GROUPS,
REQUIRED_PLUGINS,
PostgreSQL,
Expand Down Expand Up @@ -1760,6 +1761,7 @@ def _generate_ldap_service(self) -> dict:
ldap_base_dn = ldap_params["ldapbasedn"]
ldap_bind_username = ldap_params["ldapbinddn"]
ldap_bing_password = ldap_params["ldapbindpasswd"]
ldap_group_mappings = self.postgresql.build_postgresql_group_map(self.config.ldap_map)

return {
"override": "replace",
Expand All @@ -1772,6 +1774,8 @@ def _generate_ldap_service(self) -> dict:
"LDAP_BASE_DN": ldap_base_dn,
"LDAP_BIND_USERNAME": ldap_bind_username,
"LDAP_BIND_PASSWORD": ldap_bing_password,
"LDAP_GROUP_IDENTITY": json.dumps(ACCESS_GROUP_IDENTITY),
"LDAP_GROUP_MAPPINGS": json.dumps(ldap_group_mappings),
"POSTGRES_HOST": "127.0.0.1",
"POSTGRES_PORT": DATABASE_PORT,
"POSTGRES_DATABASE": DATABASE_DEFAULT_NAME,
Expand Down Expand Up @@ -2095,11 +2099,7 @@ def update_config(self, is_creating_backup: bool = False) -> bool:

self._handle_postgresql_restart_need()
self._restart_metrics_service()

# TODO: Un-comment
# When PostgreSQL-rock wrapping PostgreSQL-snap versions 162 / 163 gets published
# (i.e. snap contains https://github.com/canonical/charmed-postgresql-snap/pull/88)
# self._restart_ldap_sync_service()
self._restart_ldap_sync_service()

return True

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

if not self.postgresql.validate_group_map(self.config.ldap_map):
raise ValueError("ldap_map config option has an invalid value")

if not self.postgresql.validate_date_style(self.config.request_date_style):
raise ValueError("request_date_style config option has an invalid value")

Expand Down
1 change: 1 addition & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class CharmConfig(BaseConfigModel):
instance_max_locks_per_transaction: int | None
instance_password_encryption: str | None
instance_synchronize_seqscans: bool | None
ldap_map: str | None
ldap_search_filter: str | None
logging_client_min_messages: str | None
logging_log_connections: bool | None
Expand Down
28 changes: 18 additions & 10 deletions tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -1107,6 +1107,7 @@ def test_validate_config_options(harness):
harness.set_can_connect(POSTGRESQL_CONTAINER, True)
_charm_lib.return_value.get_postgresql_text_search_configs.return_value = []
_charm_lib.return_value.validate_date_style.return_value = []
_charm_lib.return_value.validate_group_map.return_value = False
_charm_lib.return_value.get_postgresql_timezones.return_value = []

# Test instance_default_text_search_config exception
Expand All @@ -1124,6 +1125,17 @@ def test_validate_config_options(harness):
"pg_catalog.test"
]

# Test ldap_map exception
with harness.hooks_disabled():
harness.update_config({"ldap_map": "ldap_group="})

with tc.assertRaises(ValueError) as e:
harness.charm._validate_config_options()
assert e.msg == "ldap_map config option has an invalid value"

_charm_lib.return_value.validate_group_map.assert_called_once_with("ldap_group=")
_charm_lib.return_value.validate_group_map.return_value = True

# Test request_date_style exception
with harness.hooks_disabled():
harness.update_config({"request_date_style": "ISO, TEST"})
Expand All @@ -1146,10 +1158,6 @@ def test_validate_config_options(harness):
_charm_lib.return_value.get_postgresql_timezones.assert_called_once_with()
_charm_lib.return_value.get_postgresql_timezones.return_value = ["TEST_ZONE"]

#
# Secrets
#


def test_scope_obj(harness):
assert harness.charm._scope_obj("app") == harness.charm.framework.model.app
Expand Down Expand Up @@ -1711,13 +1719,13 @@ def test_update_config(harness):
)
_handle_postgresql_restart_need.assert_called_once()
_restart_metrics_service.assert_called_once()
# _restart_ldap_sync_service.assert_called_once()
_restart_ldap_sync_service.assert_called_once()
assert "tls" not in harness.get_relation_data(rel_id, harness.charm.unit.name)

# Test with TLS files available.
_handle_postgresql_restart_need.reset_mock()
_restart_metrics_service.reset_mock()
# _restart_ldap_sync_service.reset_mock()
_restart_ldap_sync_service.reset_mock()
harness.update_relation_data(
rel_id, harness.charm.unit.name, {"tls": ""}
) # Mock some data in the relation to test that it change.
Expand All @@ -1740,7 +1748,7 @@ def test_update_config(harness):
)
_handle_postgresql_restart_need.assert_called_once()
_restart_metrics_service.assert_called_once()
# _restart_ldap_sync_service.assert_called_once()
_restart_ldap_sync_service.assert_called_once()
assert "tls" not in harness.get_relation_data(
rel_id, harness.charm.unit.name
) # The "tls" flag is set in handle_postgresql_restart_need.
Expand All @@ -1751,11 +1759,11 @@ def test_update_config(harness):
) # Mock some data in the relation to test that it change.
_handle_postgresql_restart_need.reset_mock()
_restart_metrics_service.reset_mock()
# _restart_ldap_sync_service.reset_mock()
_restart_ldap_sync_service.reset_mock()
harness.charm.update_config()
_handle_postgresql_restart_need.assert_not_called()
_restart_metrics_service.assert_not_called()
# _restart_ldap_sync_service.assert_not_called()
_restart_ldap_sync_service.assert_not_called()
assert harness.get_relation_data(rel_id, harness.charm.unit.name)["tls"] == "enabled"

# Test with member not started yet.
Expand All @@ -1765,7 +1773,7 @@ def test_update_config(harness):
harness.charm.update_config()
_handle_postgresql_restart_need.assert_not_called()
_restart_metrics_service.assert_not_called()
# _restart_ldap_sync_service.assert_not_called()
_restart_ldap_sync_service.assert_not_called()
assert "tls" not in harness.get_relation_data(rel_id, harness.charm.unit.name)


Expand Down
48 changes: 48 additions & 0 deletions tests/unit/test_postgresql.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,27 @@ def test_get_last_archived_wal(harness):
execute.assert_called_once_with("SELECT last_archived_wal FROM pg_stat_archiver;")


def test_build_postgresql_group_map(harness):
assert harness.charm.postgresql.build_postgresql_group_map(None) == []
assert harness.charm.postgresql.build_postgresql_group_map("ldap_group=admin") == []

for group in ACCESS_GROUPS:
assert harness.charm.postgresql.build_postgresql_group_map(f"ldap_group={group}") == []

mapping_1 = "ldap_group_1=psql_group_1"
mapping_2 = "ldap_group_2=psql_group_2"

assert harness.charm.postgresql.build_postgresql_group_map(f"{mapping_1},{mapping_2}") == [
("ldap_group_1", "psql_group_1"),
("ldap_group_2", "psql_group_2"),
]
try:
harness.charm.postgresql.build_postgresql_group_map(f"{mapping_1} {mapping_2}")
assert False
except ValueError:
assert True


def test_build_postgresql_parameters(harness):
# Test when not limit is imposed to the available memory.
config_options = {
Expand Down Expand Up @@ -463,3 +484,30 @@ def test_configure_pgaudit(harness):
call("ALTER SYSTEM RESET pgaudit.log_parameter;"),
call("SELECT pg_reload_conf();"),
])


def test_validate_group_map(harness):
with patch(
"charms.postgresql_k8s.v0.postgresql.PostgreSQL._connect_to_database"
) as _connect_to_database:
execute = _connect_to_database.return_value.__enter__.return_value.cursor.return_value.__enter__.return_value.execute
_connect_to_database.return_value.__enter__.return_value.cursor.return_value.__enter__.return_value.fetchone.return_value = None

query = SQL("SELECT TRUE FROM pg_roles WHERE rolname={};")

assert harness.charm.postgresql.validate_group_map(None) is True

assert harness.charm.postgresql.validate_group_map("") is False
assert harness.charm.postgresql.validate_group_map("ldap_group=") is False
execute.assert_has_calls([
call(query.format(Literal(""))),
])

assert harness.charm.postgresql.validate_group_map("ldap_group=admin") is True
assert harness.charm.postgresql.validate_group_map("ldap_group=admin,") is False
assert harness.charm.postgresql.validate_group_map("ldap_group admin") is False

assert harness.charm.postgresql.validate_group_map("ldap_group=missing_group") is False
execute.assert_has_calls([
call(query.format(Literal("missing_group"))),
])