diff --git a/config.yaml b/config.yaml index 883ef46d..a208a399 100644 --- a/config.yaml +++ b/config.yaml @@ -12,5 +12,5 @@ options: type: string hostname: description: The hostname of the LDAP server - default: "ldap.canonical.com" + default: "ldap.glauth.com" type: string diff --git a/integration-requirements.txt b/integration-requirements.txt index f2916108..2b79851c 100644 --- a/integration-requirements.txt +++ b/integration-requirements.txt @@ -1,6 +1,4 @@ -protobuf~=3.20.1 -pytest +-r requirements.txt juju +pytest pytest-operator -requests --r requirements.txt diff --git a/lib/charms/glauth_k8s/v0/ldap.py b/lib/charms/glauth_k8s/v0/ldap.py index a7f2ebf1..7e4807e0 100644 --- a/lib/charms/glauth_k8s/v0/ldap.py +++ b/lib/charms/glauth_k8s/v0/ldap.py @@ -109,7 +109,7 @@ def _on_ldap_requested(self, event: LdapRequestedEvent) -> None: ldap_data = ... # Update the integration data - self.ldap_provider.update_relation_app_data( + self.ldap_provider.update_relations_app_data( relation.id, ldap_data, ) @@ -122,11 +122,9 @@ def _on_ldap_requested(self, event: LdapRequestedEvent) -> None: LDAP related information in order to connect and authenticate to the LDAP server """ -from dataclasses import asdict, dataclass from functools import wraps -from typing import Any, Callable, Optional, Union +from typing import Any, Callable, Literal, Optional, Union -from dacite import Config, from_dict from ops.charm import ( CharmBase, RelationBrokenEvent, @@ -136,6 +134,14 @@ def _on_ldap_requested(self, event: LdapRequestedEvent) -> None: ) from ops.framework import EventSource, Object, ObjectEvents from ops.model import Relation +from pydantic import ( + BaseModel, + ConfigDict, + StrictBool, + ValidationError, + field_serializer, + field_validator, +) # The unique CharmHub library identifier, never change it LIBID = "5a535b3c4d0b40da98e29867128e57b9" @@ -147,7 +153,7 @@ def _on_ldap_requested(self, event: LdapRequestedEvent) -> None: # to 0 if you are raising the major API version LIBPATCH = 2 -PYDEPS = ["dacite~=1.8.0"] +PYDEPS = ["pydantic~=2.5.3"] DEFAULT_RELATION_NAME = "ldap" @@ -176,18 +182,43 @@ def _update_relation_app_databag( relation.data[ldap.app].update(data) -@dataclass(frozen=True) -class LdapProviderData: +class LdapProviderBaseData(BaseModel): + model_config = ConfigDict(frozen=True) + url: str base_dn: str + + @field_validator("url") + @classmethod + def validate_ldap_url(cls, v: str) -> str: + if not v.startswith("ldap://"): + raise ValidationError("Invalid LDAP URL scheme.") + + return v + + +class LdapProviderData(LdapProviderBaseData): bind_dn: str bind_password_secret: str - auth_method: str - starttls: bool + auth_method: Literal["simple"] + starttls: StrictBool + + @field_validator("starttls", mode="before") + @classmethod + def deserialize_bool(cls, v: str | bool) -> bool: + if isinstance(v, str): + return True if v.casefold() == "true" else False + + return v + @field_serializer("starttls") + def serialize_bool(self, starttls: bool) -> str: + return str(starttls) + + +class LdapRequirerData(BaseModel): + model_config = ConfigDict(frozen=True) -@dataclass(frozen=True) -class LdapRequirerData: user: str group: str @@ -198,9 +229,7 @@ class LdapRequestedEvent(RelationEvent): @property def data(self) -> Optional[LdapRequirerData]: relation_data = self.relation.data.get(self.relation.app) - return ( - from_dict(data_class=LdapRequirerData, data=relation_data) if relation_data else None - ) + return LdapRequirerData(**relation_data) if relation_data else None class LdapProviderEvents(ObjectEvents): @@ -245,15 +274,21 @@ def _on_relation_changed(self, event: RelationChangedEvent) -> None: """Handle the event emitted when the requirer charm provides the necessary data.""" self.on.ldap_requested.emit(event.relation) - def update_relation_app_data( - self, /, relation_id: int, data: Optional[LdapProviderData] + def update_relations_app_data( + self, /, data: Optional[LdapProviderBaseData] = None, relation_id: Optional[int] = None ) -> None: """An API for the provider charm to provide the LDAP related information.""" if data is None: return - relation = self.charm.model.get_relation(self._relation_name, relation_id) - _update_relation_app_databag(self.charm, relation, asdict(data)) + if not (relations := self.charm.model.relations.get(self._relation_name)): + return + + if relation_id is not None: + relations = [relation for relation in relations if relation.id == relation_id] + + for relation in relations: + _update_relation_app_databag(self.charm, relation, data.model_dump()) class LdapRequirer(Object): @@ -320,12 +355,4 @@ def consume_ldap_relation_data( return None provider_data = relation.data.get(relation.app) - return ( - from_dict( - data_class=LdapProviderData, - data=provider_data, - config=Config(cast=[bool]), - ) - if provider_data - else None - ) + return LdapProviderData(**provider_data) if provider_data else None diff --git a/requirements.txt b/requirements.txt index d9d34d91..8628906f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ cosl -dacite ~= 1.8.0 Jinja2 lightkube lightkube-models ops >= 2.2.0 psycopg[binary] +pydantic ~= 2.5.3 SQLAlchemy tenacity ~= 8.2.3 diff --git a/src/charm.py b/src/charm.py index 3a6559e0..0a945fd6 100755 --- a/src/charm.py +++ b/src/charm.py @@ -189,6 +189,9 @@ def _on_database_changed(self, event: DatabaseEndpointsChangedEvent) -> None: def _on_config_changed(self, event: ConfigChangedEvent) -> None: self.config_file.base_dn = self.config.get("base_dn") self._handle_event_update(event) + self.ldap_provider.update_relations_app_data( + data=self._ldap_integration.provider_base_data + ) @validate_container_connectivity def _on_pebble_ready(self, event: PebbleReadyEvent) -> None: @@ -207,9 +210,9 @@ def _on_ldap_requested(self, event: LdapRequestedEvent) -> None: return self._ldap_integration.load_bind_account(requirer_data.user, requirer_data.group) - self.ldap_provider.update_relation_app_data( - event.relation.id, - self._ldap_integration.provider_data, + self.ldap_provider.update_relations_app_data( + relation_id=event.relation.id, + data=self._ldap_integration.provider_data, ) def _on_promtail_error(self, event: PromtailDigestError) -> None: diff --git a/src/integrations.py b/src/integrations.py index 3f112603..dfdc215f 100644 --- a/src/integrations.py +++ b/src/integrations.py @@ -4,7 +4,7 @@ from secrets import token_bytes from typing import Optional -from charms.glauth_k8s.v0.ldap import LdapProviderData +from charms.glauth_k8s.v0.ldap import LdapProviderBaseData, LdapProviderData from configs import DatabaseConfig from constants import DEFAULT_GID, DEFAULT_UID, GLAUTH_LDAP_PORT from database import Capability, Group, Operation, User @@ -53,15 +53,30 @@ def load_bind_account(self, user: str, group: str) -> None: database_config = DatabaseConfig.load(self._charm.database_requirer) self._bind_account = _create_bind_account(database_config.dsn, user, group) + @property + def ldap_url(self) -> str: + return f"ldap://{self._charm.config.get('hostname')}:{GLAUTH_LDAP_PORT}" + + @property + def base_dn(self) -> str: + return self._charm.config.get("base_dn") + + @property + def provider_base_data(self) -> LdapProviderBaseData: + return LdapProviderBaseData( + url=self.ldap_url, + base_dn=self.base_dn, + ) + @property def provider_data(self) -> Optional[LdapProviderData]: if not self._bind_account: return None return LdapProviderData( - url=f"ldap://{self._charm.config.get('hostname')}:{GLAUTH_LDAP_PORT}", - base_dn=self._charm.config.get("base_dn"), - bind_dn=f"cn={self._bind_account.cn},ou={self._bind_account.ou},{self._charm.config.get('base_dn')}", + url=self.ldap_url, + base_dn=self.base_dn, + bind_dn=f"cn={self._bind_account.cn},ou={self._bind_account.ou},{self.base_dn}", bind_password_secret=self._bind_account.password or "", auth_method="simple", starttls=True,