Skip to content

Commit

Permalink
feat: ldap interface integration in the glauth-k8s
Browse files Browse the repository at this point in the history
  • Loading branch information
wood-push-melon committed Jan 5, 2024
1 parent 581d684 commit 493ccbc
Show file tree
Hide file tree
Showing 12 changed files with 285 additions and 94 deletions.
8 changes: 6 additions & 2 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion lib/charms/glauth_k8s/v0/ldap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
4 changes: 4 additions & 0 deletions metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
cosl
dacite ~= 1.8.0
Jinja2
lightkube
lightkube-models
ops >= 2.2.0
psycopg[binary]
SQLAlchemy
tenacity ~= 8.2.3
32 changes: 28 additions & 4 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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)

Expand Down
13 changes: 11 additions & 2 deletions src/configs.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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()

Expand Down
5 changes: 5 additions & 0 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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")
76 changes: 76 additions & 0 deletions src/database.py
Original file line number Diff line number Diff line change
@@ -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)
68 changes: 68 additions & 0 deletions src/integrations.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading

0 comments on commit 493ccbc

Please sign in to comment.