diff --git a/interfaces/ldap/CHANGELOG.md b/interfaces/ldap/CHANGELOG.md index 34a938276..a191b8588 100644 --- a/interfaces/ldap/CHANGELOG.md +++ b/interfaces/ldap/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.1.0 - 11 September 2026 + +- Add `ldaps_enabled` convenience property to `LdapProviderBaseData` (derived from `ldaps_urls`). + ## 1.0.0 Initial release. Migrated from `charms.glauth_k8s.v0.ldap` (v0.13). diff --git a/interfaces/ldap/interface/v0/README.md b/interfaces/ldap/interface/v0/README.md index 74dc34fee..4cb7185cf 100644 --- a/interfaces/ldap/interface/v0/README.md +++ b/interfaces/ldap/interface/v0/README.md @@ -17,7 +17,7 @@ be able to provide or consume the LDAP authentication configuration data. ```mermaid flowchart TD Requirer -- user, \ngroup --> Provider - Provider -- urls, \nbase_dn, \nbind_dn, \nbind_password_secret, \nauth_method, \nstarttls --> Requirer + Provider -- urls, \nldaps_urls, \nbase_dn, \nbind_dn, \nbind_password_secret, \nauth_method, \nstarttls --> Requirer ``` ## Behavior @@ -69,12 +69,13 @@ It should be placed in the **application** databag. related-endpoint: ldap application-data: urls: [ldap://ldap.canonical.com:3893, ldap://ldap.ubuntu.com:3893] + ldaps_urls: [ldaps://ldap.canonical.com:3894, ldaps://ldap.ubuntu.com:3894] base_dn: dc=canonical,dc=com bind_dn: cn=app,ou=model,dc=canonical,dc=com bind_password_secret: secret://59060ecc-0495-4a80-8006-5f1fc13fd783/cjqub6vubg2s77p3nio0 auth_method: simple starttls: true -`````` +``` ### Requirer diff --git a/interfaces/ldap/interface/v0/schema.py b/interfaces/ldap/interface/v0/schema.py index 649dd24d1..3cfa479eb 100644 --- a/interfaces/ldap/interface/v0/schema.py +++ b/interfaces/ldap/interface/v0/schema.py @@ -23,6 +23,12 @@ class LdapProviderData(BaseModel): title='LDAP URLs', example=['ldap://ldap.canonical.com:3893', 'ldap://ldap.ubuntu.com:3893'], ) + ldaps_urls: list[AnyUrl] = Field( + default=[], + description='List of LDAPS URLs', + title='LDAPS URLs', + example=['ldaps://ldap.canonical.com:3894', 'ldaps://ldap.ubuntu.com:3894'], + ) base_dn: str = Field( description='The base entry as the starting point for LDAP search operation', title='Base DN', diff --git a/interfaces/ldap/src/charmlibs/interfaces/ldap/__init__.py b/interfaces/ldap/src/charmlibs/interfaces/ldap/__init__.py index 7e315e2d0..97fd04033 100644 --- a/interfaces/ldap/src/charmlibs/interfaces/ldap/__init__.py +++ b/interfaces/ldap/src/charmlibs/interfaces/ldap/__init__.py @@ -138,6 +138,7 @@ def _on_ldap_requested(self, event: LdapRequestedEvent) -> None: from ._ldap import ( LdapProvider, + LdapProviderBaseData, LdapProviderData, LdapReadyEvent, LdapRequestedEvent, @@ -149,6 +150,7 @@ def _on_ldap_requested(self, event: LdapRequestedEvent) -> None: __all__ = [ 'LdapProvider', + 'LdapProviderBaseData', 'LdapProviderData', 'LdapReadyEvent', 'LdapRequestedEvent', diff --git a/interfaces/ldap/src/charmlibs/interfaces/ldap/_ldap.py b/interfaces/ldap/src/charmlibs/interfaces/ldap/_ldap.py index e6deae8f0..8030ab2a2 100644 --- a/interfaces/ldap/src/charmlibs/interfaces/ldap/_ldap.py +++ b/interfaces/ldap/src/charmlibs/interfaces/ldap/_ldap.py @@ -115,6 +115,11 @@ class LdapProviderBaseData(BaseModel): base_dn: str = Field(frozen=True) starttls: StrictBool = Field(frozen=True) + @property + def ldaps_enabled(self) -> bool: + """Whether LDAPS is enabled based on the presence of LDAPS URLs.""" + return bool(self.ldaps_urls) + @field_validator('urls', mode='before') @classmethod def validate_ldap_urls(cls, vs: list[str] | str) -> list[str]: diff --git a/interfaces/ldap/src/charmlibs/interfaces/ldap/_version.py b/interfaces/ldap/src/charmlibs/interfaces/ldap/_version.py index a5443891f..f9e828ee1 100644 --- a/interfaces/ldap/src/charmlibs/interfaces/ldap/_version.py +++ b/interfaces/ldap/src/charmlibs/interfaces/ldap/_version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = '1.0.0' +__version__ = '1.1.0' diff --git a/interfaces/ldap/tests/unit/test_ldap_provider.py b/interfaces/ldap/tests/unit/test_ldap_provider.py new file mode 100644 index 000000000..a47f2b8ee --- /dev/null +++ b/interfaces/ldap/tests/unit/test_ldap_provider.py @@ -0,0 +1,99 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +from typing import Any + +import pytest +import yaml +from ops import CharmBase +from ops.testing import Context, Relation, State + +from charmlibs.interfaces.ldap import ( + LdapProvider, + LdapProviderBaseData, + LdapProviderData, +) + +PROVIDER_METADATA = """ +name: provider-tester +provides: + ldap: + interface: ldap +""" + + +class LdapProviderCharm(CharmBase): + """Test charm that wraps LdapProvider.""" + + def __init__(self, *args: Any) -> None: + super().__init__(*args) + self.ldap_provider = LdapProvider(self) + + +@pytest.fixture +def provider_context() -> Context: + """ops.testing Context for the test LdapProviderCharm.""" + return Context(LdapProviderCharm, meta=yaml.safe_load(PROVIDER_METADATA), juju_version='3.2.1') + + +@pytest.mark.parametrize( + 'ldaps_urls, expected_enabled', + [ + (['ldaps://path.to.glauth:3894'], True), + ([], False), + ], +) +def test_update_relations_app_data_ldaps_urls_and_property( + provider_context: Context, ldaps_urls: list[str], expected_enabled: bool +) -> None: + relation = Relation('ldap') + state = State(leader=True, relations=[relation]) + + with provider_context(provider_context.on.update_status(), state) as mgr: + data = LdapProviderData( + urls=['ldap://path.to.glauth:3893'], + ldaps_urls=ldaps_urls, + base_dn='dc=glauth,dc=com', + bind_dn='cn=serviceuser,ou=svcaccts,dc=glauth,dc=com', + bind_password='password', + auth_method='simple', + starttls=True, + ) + assert data.ldaps_enabled is expected_enabled + + mgr.charm.ldap_provider.update_relations_app_data(data, relation_id=relation.id) + state_out = mgr.run() + + app_data = state_out.get_relation(relation.id).local_app_data + assert app_data['ldaps_urls'] == json.dumps(ldaps_urls) + assert 'ldaps_enabled' not in app_data + + +def test_base_data_ldaps_enabled_property() -> None: + data_with_ldaps = LdapProviderBaseData( + urls=['ldap://path.to.glauth:3893'], + ldaps_urls=['ldaps://path.to.glauth:3894'], + base_dn='dc=glauth,dc=com', + starttls=True, + ) + assert data_with_ldaps.ldaps_enabled is True + + data_without_ldaps = LdapProviderBaseData( + urls=['ldap://path.to.glauth:3893'], + ldaps_urls=[], + base_dn='dc=glauth,dc=com', + starttls=True, + ) + assert data_without_ldaps.ldaps_enabled is False diff --git a/interfaces/ldap/tests/unit/test_ldap_requirer.py b/interfaces/ldap/tests/unit/test_ldap_requirer.py index 76d301cba..ad0faa610 100644 --- a/interfaces/ldap/tests/unit/test_ldap_requirer.py +++ b/interfaces/ldap/tests/unit/test_ldap_requirer.py @@ -14,6 +14,7 @@ import json +import pytest from ops.testing import Context, Model, Relation, Secret, State from charmlibs.interfaces.ldap import LdapReadyEvent, LdapUnavailableEvent @@ -79,6 +80,31 @@ def test_consume_ldap_relation_data(context: Context, provider_data: dict[str, s assert result.bind_dn == provider_data['bind_dn'] assert result.bind_password == password assert result.bind_password_secret == secret.id + assert result.ldaps_enabled is True + + +@pytest.mark.parametrize( + 'ldaps_urls_json, expected', + [ + ('["ldaps://path.to.glauth:3894"]', True), + ('[]', False), + ], +) +def test_consume_ldap_relation_data_ldaps_enabled( + context: Context, provider_data: dict[str, str], ldaps_urls_json: str, expected: bool +) -> None: + password = 'p4ssw0rd' + secret = Secret(id='secret:bind-0003', tracked_content={'password': password}) + data = {**provider_data, 'bind_password_secret': secret.id, 'ldaps_urls': ldaps_urls_json} + relation = Relation('ldap', remote_app_data=data) + state = create_state(leader=True, relations=[relation], secrets=[secret], containers=[]) + + with context(context.on.relation_changed(relation), state) as mgr: + mgr.run() + result = mgr.charm.ldap_requirer.consume_ldap_relation_data() + + assert result is not None + assert result.ldaps_enabled is expected def test_consume_ldap_relation_data_inaccessible_secret(