diff --git a/config.yaml b/config.yaml index 86da6cbc82..2cf3d4e3cb 100644 --- a/config.yaml +++ b/config.yaml @@ -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: =,= + type: string ldap_search_filter: description: | The LDAP search filter to match users with. diff --git a/lib/charms/postgresql_k8s/v0/postgresql.py b/lib/charms/postgresql_k8s/v0/postgresql.py index 41be98a04f..5975197f1b 100644 --- a/lib/charms/postgresql_k8s/v0/postgresql.py +++ b/lib/charms/postgresql_k8s/v0/postgresql.py @@ -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" @@ -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: + =, + =, + ... + + 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 @@ -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: + =, + =, + ... + + 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 diff --git a/src/charm.py b/src/charm.py index 3726e34ea9..b3f11cd0bb 100755 --- a/src/charm.py +++ b/src/charm.py @@ -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, @@ -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", @@ -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, @@ -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 @@ -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") diff --git a/src/config.py b/src/config.py index 9e169efa95..9932a06d89 100644 --- a/src/config.py +++ b/src/config.py @@ -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 diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index f93053c817..c169047fd0 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -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 @@ -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"}) @@ -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 @@ -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. @@ -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. @@ -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. @@ -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) diff --git a/tests/unit/test_postgresql.py b/tests/unit/test_postgresql.py index e87f9ba370..d62baec568 100644 --- a/tests/unit/test_postgresql.py +++ b/tests/unit/test_postgresql.py @@ -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 = { @@ -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"))), + ])