From 493ccbca09b7ee53c0595fbd382a4ffb8b7dafd6 Mon Sep 17 00:00:00 2001 From: dushu Date: Thu, 4 Jan 2024 22:30:53 -0600 Subject: [PATCH] feat: ldap interface integration in the glauth-k8s --- config.yaml | 8 +- lib/charms/glauth_k8s/v0/ldap.py | 7 +- metadata.yaml | 4 + requirements.txt | 3 + src/charm.py | 32 +++++++- src/configs.py | 13 ++- src/constants.py | 5 ++ src/database.py | 76 ++++++++++++++++++ src/integrations.py | 68 ++++++++++++++++ src/utils.py | 79 +++++++++++++++++- src/validators.py | 80 ------------------- .../{test_validators.py => test_utils.py} | 4 +- 12 files changed, 285 insertions(+), 94 deletions(-) create mode 100644 src/database.py create mode 100644 src/integrations.py delete mode 100644 src/validators.py rename tests/unit/{test_validators.py => test_utils.py} (98%) diff --git a/config.yaml b/config.yaml index aaee4562..883ef46d 100644 --- a/config.yaml +++ b/config.yaml @@ -1,12 +1,16 @@ options: log_level: description: | - Configures the log level. + Configures the log level. Acceptable values are: "info", "debug", "warning", "error" and "critical" default: "info" type: string base_dn: - description: base DN + description: The base DN default: "dc=glauth,dc=com" type: string + hostname: + description: The hostname of the LDAP server + default: "ldap.canonical.com" + type: string diff --git a/lib/charms/glauth_k8s/v0/ldap.py b/lib/charms/glauth_k8s/v0/ldap.py index 8f3b7a79..a7f2ebf1 100644 --- a/lib/charms/glauth_k8s/v0/ldap.py +++ b/lib/charms/glauth_k8s/v0/ldap.py @@ -245,8 +245,13 @@ 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: LdapProviderData) -> None: + def update_relation_app_data( + self, /, relation_id: int, data: Optional[LdapProviderData] + ) -> 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)) diff --git a/metadata.yaml b/metadata.yaml index c81208cd..8983a9de 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -36,3 +36,7 @@ provides: description: | Forwards the built-in grafana dashboard(s) for monitoring GLAuth. interface: grafana_dashboard + ldap: + description: | + Provides LDAP configuration data + interface: ldap diff --git a/requirements.txt b/requirements.txt index acff6bce..d9d34d91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,9 @@ cosl +dacite ~= 1.8.0 Jinja2 lightkube lightkube-models ops >= 2.2.0 +psycopg[binary] +SQLAlchemy tenacity ~= 8.2.3 diff --git a/src/charm.py b/src/charm.py index 4de88b2a..3a6559e0 100755 --- a/src/charm.py +++ b/src/charm.py @@ -14,6 +14,7 @@ DatabaseEndpointsChangedEvent, DatabaseRequires, ) +from charms.glauth_k8s.v0.ldap import LdapProvider, LdapRequestedEvent from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider from charms.loki_k8s.v0.loki_push_api import LogProxyConsumer, PromtailDigestError from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch @@ -30,6 +31,7 @@ PROMETHEUS_SCRAPE_INTEGRATION_NAME, WORKLOAD_CONTAINER, ) +from integrations import LdapIntegration from kubernetes_resource import ConfigMapResource, StatefulSetResource from lightkube import Client from ops.charm import ( @@ -43,8 +45,8 @@ from ops.main import main from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus from ops.pebble import ChangeError -from utils import after_config_updated -from validators import ( +from utils import ( + after_config_updated, leader_unit, validate_container_connectivity, validate_database_resource, @@ -73,6 +75,12 @@ def __init__(self, *args: Any): extra_user_roles="SUPERUSER", ) + self.ldap_provider = LdapProvider(self) + self.framework.observe( + self.ldap_provider.on.ldap_requested, + self._on_ldap_requested, + ) + self.service_patcher = KubernetesServicePatch(self, [("ldap", GLAUTH_LDAP_PORT)]) self.loki_consumer = LogProxyConsumer( @@ -104,6 +112,7 @@ def __init__(self, *args: Any): ) self.config_file = ConfigFile(base_dn=self.config.get("base_dn")) + self._ldap_integration = LdapIntegration(self) @after_config_updated def _restart_glauth_service(self) -> None: @@ -121,6 +130,8 @@ def _restart_glauth_service(self) -> None: def _handle_event_update(self, event: HookEvent) -> None: self.unit.status = MaintenanceStatus("Configuring GLAuth container") + self.config_file.database_config = DatabaseConfig.load(self.database_requirer) + self._update_glauth_config() self._container.add_layer(WORKLOAD_CONTAINER, pebble_layer, combine=True) @@ -166,14 +177,13 @@ def _on_remove(self, event: RemoveEvent) -> None: self._configmap.delete() def _on_database_created(self, event: DatabaseCreatedEvent) -> None: - self.config_file.database_config = DatabaseConfig.load_config(self.database_requirer) + self.config_file.database_config = DatabaseConfig.load(self.database_requirer) self._update_glauth_config() self._container.add_layer(WORKLOAD_CONTAINER, pebble_layer, combine=True) self._restart_glauth_service() self.unit.status = ActiveStatus() def _on_database_changed(self, event: DatabaseEndpointsChangedEvent) -> None: - self.config_file.database_config = DatabaseConfig.load_config(self.database_requirer) self._handle_event_update(event) def _on_config_changed(self, event: ConfigChangedEvent) -> None: @@ -188,6 +198,20 @@ def _on_pebble_ready(self, event: PebbleReadyEvent) -> None: self._handle_event_update(event) + @validate_database_resource + def _on_ldap_requested(self, event: LdapRequestedEvent) -> None: + if not (requirer_data := event.data): + logger.error( + f"The LDAP requirer {event.app.name} does not provide " f"necessary data." + ) + 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, + ) + def _on_promtail_error(self, event: PromtailDigestError) -> None: logger.error(event.message) diff --git a/src/configs.py b/src/configs.py index cdea8079..c71789dd 100644 --- a/src/configs.py +++ b/src/configs.py @@ -1,7 +1,7 @@ from dataclasses import asdict, dataclass from typing import Any, Optional -from constants import GLAUTH_COMMANDS, LOG_FILE, WORKLOAD_SERVICE +from constants import GLAUTH_COMMANDS, LOG_FILE, POSTGRESQL_DSN_TEMPLATE, WORKLOAD_SERVICE from jinja2 import Template from ops.pebble import Layer @@ -13,8 +13,17 @@ class DatabaseConfig: username: Optional[str] = None password: Optional[str] = None + @property + def dsn(self) -> str: + return POSTGRESQL_DSN_TEMPLATE.substitute( + username=self.username, + password=self.password, + endpoint=self.endpoint, + database=self.database, + ) + @classmethod - def load_config(cls, requirer: Any) -> "DatabaseConfig": + def load(cls, requirer: Any) -> "DatabaseConfig": if not (database_integrations := requirer.relations): return DatabaseConfig() diff --git a/src/constants.py b/src/constants.py index c43604e3..c6350b0c 100644 --- a/src/constants.py +++ b/src/constants.py @@ -2,6 +2,7 @@ # See LICENSE file for licensing details. from pathlib import PurePath +from string import Template DATABASE_INTEGRATION_NAME = "pg-database" LOKI_API_PUSH_INTEGRATION_NAME = "logging" @@ -18,3 +19,7 @@ WORKLOAD_CONTAINER = "glauth" WORKLOAD_SERVICE = "glauth" + +DEFAULT_UID = 5001 +DEFAULT_GID = 5501 +POSTGRESQL_DSN_TEMPLATE = Template("postgresql+psycopg://$username:$password@$endpoint/$database") diff --git a/src/database.py b/src/database.py new file mode 100644 index 00000000..4bf458dc --- /dev/null +++ b/src/database.py @@ -0,0 +1,76 @@ +import logging +from typing import Any, Optional, Type + +from sqlalchemy import ( + ColumnExpressionArgument, + Integer, + ScalarResult, + String, + create_engine, + select, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column + +logger = logging.getLogger(__name__) + + +class Base(DeclarativeBase): + pass + + +# https://github.com/glauth/glauth-postgres/blob/main/postgres.go +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String, name="name", unique=True) + uid_number: Mapped[int] = mapped_column(name="uidnumber") + gid_number: Mapped[int] = mapped_column(name="primarygroup") + password_sha256: Mapped[Optional[str]] = mapped_column(name="passsha256") + password_bcrypt: Mapped[Optional[str]] = mapped_column(name="passbcrypt") + + +class Group(Base): + __tablename__ = "groups" + + id = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(name="name", unique=True) + gid_number: Mapped[int] = mapped_column(name="gidnumber") + + +class Capability(Base): + __tablename__ = "capabilities" + + id = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(name="userid") + action: Mapped[str] = mapped_column(default="search") + object: Mapped[str] = mapped_column(default="*") + + +class Operation: + def __init__(self, dsn: str) -> None: + self._dsn = dsn + + def __enter__(self) -> "Operation": + engine = create_engine(self._dsn) + self._session = Session(engine) + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + if exc_type: + logger.error( + f"The database operation failed. The exception " f"{exc_type} raised: {exc_val}" + ) + self._session.rollback() + else: + self._session.commit() + + self._session.close() + + def select( + self, table: Type[Base], *criteria: ColumnExpressionArgument + ) -> Optional[ScalarResult]: + return self._session.scalars(select(table).filter(*criteria)).first() + + def add(self, entity: Base) -> None: + self._session.add(entity) diff --git a/src/integrations.py b/src/integrations.py new file mode 100644 index 00000000..3f112603 --- /dev/null +++ b/src/integrations.py @@ -0,0 +1,68 @@ +import hashlib +import logging +from dataclasses import dataclass +from secrets import token_bytes +from typing import Optional + +from charms.glauth_k8s.v0.ldap import LdapProviderData +from configs import DatabaseConfig +from constants import DEFAULT_GID, DEFAULT_UID, GLAUTH_LDAP_PORT +from database import Capability, Group, Operation, User +from ops.charm import CharmBase + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class BindAccount: + cn: Optional[str] = None + ou: Optional[str] = None + password: Optional[str] = None + + +def _create_bind_account(dsn: str, user_name: str, group_name: str) -> BindAccount: + with Operation(dsn) as op: + if not op.select(Group, Group.name == group_name): + group = Group(name=group_name, gid_number=DEFAULT_GID) + op.add(group) + + if not (user := op.select(User, User.name == user_name)): + new_password = hashlib.sha256(token_bytes()).hexdigest() + user = User( + name=user_name, + uid_number=DEFAULT_UID, + gid_number=DEFAULT_GID, + password_sha256=new_password, + ) + op.add(user) + password = user.password_bcrypt or user.password_sha256 + + if not op.select(Capability, Capability.user_id == DEFAULT_UID): + capability = Capability(user_id=DEFAULT_UID) + op.add(capability) + + return BindAccount(user_name, group_name, password) + + +class LdapIntegration: + def __init__(self, charm: CharmBase): + self._charm = charm + self._bind_account: Optional[BindAccount] = None + + 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 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')}", + bind_password_secret=self._bind_account.password or "", + auth_method="simple", + starttls=True, + ) diff --git a/src/utils.py b/src/utils.py index 6d295be8..eb9f3b77 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,13 +1,86 @@ # Copyright 2023 Canonical Ltd. # See LICENSE file for licensing details. +import logging from functools import wraps from typing import Any, Callable, Optional from constants import GLAUTH_CONFIG_FILE -from ops.charm import CharmBase +from ops.charm import CharmBase, EventBase +from ops.model import BlockedStatus, WaitingStatus from tenacity import Retrying, TryAgain, wait_fixed +logger = logging.getLogger(__name__) + + +def leader_unit(func: Callable) -> Callable: + @wraps(func) + def wrapper(charm: CharmBase, *args: Any, **kwargs: Any) -> Optional[Any]: + if not charm.unit.is_leader(): + return None + + return func(charm, *args, **kwargs) + + return wrapper + + +def validate_container_connectivity(func: Callable) -> Callable: + @wraps(func) + def wrapper(charm: CharmBase, *args: EventBase, **kwargs: Any) -> Optional[Any]: + event, *_ = args + logger.debug(f"Handling event: {event}") + if not charm._container.can_connect(): + logger.debug(f"Cannot connect to container, defer event {event}.") + event.defer() + + charm.unit.status = WaitingStatus("Waiting to connect to container.") + return None + + return func(charm, *args, **kwargs) + + return wrapper + + +def validate_integration_exists(integration_name: str) -> Callable: + def decorator(func: Callable) -> Callable: + @wraps(func) + def wrapper(charm: CharmBase, *args: EventBase, **kwargs: Any) -> Optional[Any]: + event, *_ = args + logger.debug(f"Handling event: {event}") + + if not charm.model.relations[integration_name]: + logger.debug(f"Integration {integration_name} is missing, defer event {event}.") + event.defer() + + charm.unit.status = BlockedStatus( + f"Missing required integration {integration_name}" + ) + return None + + return func(charm, *args, **kwargs) + + return wrapper + + return decorator + + +def validate_database_resource(func: Callable) -> Callable: + @wraps(func) + def wrapper(charm: CharmBase, *args: EventBase, **kwargs: Any) -> Optional[Any]: + event, *_ = args + logger.info(f"Handling event: {event}") + + if not charm.database_requirer.is_resource_created(): + logger.info(f"Database has not been created yet, defer event {event}") + event.defer() + + charm.unit.status = WaitingStatus("Waiting for database creation") + return None + + return func(charm, *args, **kwargs) + + return wrapper + def after_config_updated(func: Callable) -> Callable: @wraps(func) @@ -15,9 +88,9 @@ def wrapper(charm: CharmBase, *args: Any, **kwargs: Any) -> Optional[Any]: for attempt in Retrying( wait=wait_fixed(3), ): + expected_config = charm.config_file.content + current_config = charm._container.pull(GLAUTH_CONFIG_FILE).read() with attempt: - expected_config = charm.config_file.content - current_config = charm._container.pull(GLAUTH_CONFIG_FILE).read() if expected_config != current_config: raise TryAgain diff --git a/src/validators.py b/src/validators.py deleted file mode 100644 index 8f323985..00000000 --- a/src/validators.py +++ /dev/null @@ -1,80 +0,0 @@ -# Copyright 2023 Canonical Ltd. -# See LICENSE file for licensing details. - -import logging -from functools import wraps -from typing import Any, Callable, Optional - -from ops.charm import CharmBase, EventBase -from ops.model import BlockedStatus, WaitingStatus - -logger = logging.getLogger(__name__) - - -def leader_unit(func: Callable) -> Callable: - @wraps(func) - def wrapper(charm: CharmBase, *args: Any, **kwargs: Any) -> Optional[Any]: - if not charm.unit.is_leader(): - return None - - return func(charm, *args, **kwargs) - - return wrapper - - -def validate_container_connectivity(func: Callable) -> Callable: - @wraps(func) - def wrapper(charm: CharmBase, *args: EventBase, **kwargs: Any) -> Optional[Any]: - event, *_ = args - logger.debug(f"Handling event: {event}") - if not charm._container.can_connect(): - logger.debug(f"Cannot connect to container, defer event {event}.") - event.defer() - - charm.unit.status = WaitingStatus("Waiting to connect to container.") - return None - - return func(charm, *args, **kwargs) - - return wrapper - - -def validate_integration_exists(integration_name: str) -> Callable: - def decorator(func: Callable) -> Callable: - @wraps(func) - def wrapper(charm: CharmBase, *args: EventBase, **kwargs: Any) -> Optional[Any]: - event, *_ = args - logger.debug(f"Handling event: {event}") - - if not charm.model.relations[integration_name]: - logger.debug(f"Integration {integration_name} is missing, defer event {event}.") - event.defer() - - charm.unit.status = BlockedStatus( - f"Missing required integration {integration_name}" - ) - return None - - return func(charm, *args, **kwargs) - - return wrapper - - return decorator - - -def validate_database_resource(func: Callable) -> Callable: - @wraps(func) - def wrapper(charm: CharmBase, *args: EventBase, **kwargs: Any) -> Optional[Any]: - event, *_ = args - logger.debug(f"Handling event: {event}") - - if not charm.database_requirer.is_resource_created(): - logger.debug(f"Database has not been created yet, defer event {event}") - event.defer() - - charm.unit.status = WaitingStatus("Waiting for database creation") - return None - - return func(charm, *args, **kwargs) - - return wrapper diff --git a/tests/unit/test_validators.py b/tests/unit/test_utils.py similarity index 98% rename from tests/unit/test_validators.py rename to tests/unit/test_utils.py index 594ca4c3..de436db1 100644 --- a/tests/unit/test_validators.py +++ b/tests/unit/test_utils.py @@ -7,7 +7,7 @@ from ops.charm import CharmBase, HookEvent from ops.model import BlockedStatus, WaitingStatus from ops.testing import Harness -from validators import ( +from utils import ( leader_unit, validate_container_connectivity, validate_database_resource, @@ -15,7 +15,7 @@ ) -class TestValidators: +class TestUtils: def test_leader_unit(self, harness: Harness) -> None: @leader_unit def wrapped_func(charm: CharmBase) -> sentinel: