From c2bb4b25cb849b56718fedc3ae18b3e4e10895ca Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 5 Jan 2026 21:30:26 +0000 Subject: [PATCH] Implement native LDAP authentication support - Create archivebox/config/ldap.py with LDAPConfig class - Create archivebox/ldap/ Django app with custom auth backend - Update core/settings.py to conditionally load LDAP when enabled - Add LDAP_CREATE_SUPERUSER support to auto-grant superuser privileges - Add comprehensive tests in test_auth_ldap.py (no mocks, no skips) - LDAP only activates if django-auth-ldap is installed and LDAP_ENABLED=True - Helpful error messages when LDAP libraries are missing or config is incomplete Fixes #1664 Co-authored-by: Nick Sweeting --- archivebox/config/__init__.py | 2 + archivebox/config/ldap.py | 56 ++++++++ archivebox/core/settings.py | 66 +++++++-- archivebox/ldap/__init__.py | 17 +++ archivebox/ldap/apps.py | 13 ++ archivebox/ldap/auth.py | 49 +++++++ archivebox/tests/test_auth_ldap.py | 218 +++++++++++++++++++++++++++++ 7 files changed, 413 insertions(+), 8 deletions(-) create mode 100644 archivebox/config/ldap.py create mode 100644 archivebox/ldap/__init__.py create mode 100644 archivebox/ldap/apps.py create mode 100644 archivebox/ldap/auth.py create mode 100644 archivebox/tests/test_auth_ldap.py diff --git a/archivebox/config/__init__.py b/archivebox/config/__init__.py index fd0e2850..246a2e0c 100644 --- a/archivebox/config/__init__.py +++ b/archivebox/config/__init__.py @@ -92,6 +92,7 @@ def get_CONFIG(): ARCHIVING_CONFIG, SEARCH_BACKEND_CONFIG, ) + from .ldap import LDAP_CONFIG return { 'SHELL_CONFIG': SHELL_CONFIG, 'STORAGE_CONFIG': STORAGE_CONFIG, @@ -99,4 +100,5 @@ def get_CONFIG(): 'SERVER_CONFIG': SERVER_CONFIG, 'ARCHIVING_CONFIG': ARCHIVING_CONFIG, 'SEARCHBACKEND_CONFIG': SEARCH_BACKEND_CONFIG, + 'LDAP_CONFIG': LDAP_CONFIG, } diff --git a/archivebox/config/ldap.py b/archivebox/config/ldap.py new file mode 100644 index 00000000..2fe146a1 --- /dev/null +++ b/archivebox/config/ldap.py @@ -0,0 +1,56 @@ +__package__ = "archivebox.config" + +from typing import Optional +from pydantic import Field + +from archivebox.config.configset import BaseConfigSet + + +class LDAPConfig(BaseConfigSet): + """ + LDAP authentication configuration. + + Only loads and validates if django-auth-ldap is installed. + These settings integrate with Django's LDAP authentication backend. + """ + toml_section_header: str = "LDAP_CONFIG" + + LDAP_ENABLED: bool = Field(default=False) + LDAP_SERVER_URI: Optional[str] = Field(default=None) + LDAP_BIND_DN: Optional[str] = Field(default=None) + LDAP_BIND_PASSWORD: Optional[str] = Field(default=None) + LDAP_USER_BASE: Optional[str] = Field(default=None) + LDAP_USER_FILTER: str = Field(default="(uid=%(user)s)") + LDAP_USERNAME_ATTR: str = Field(default="username") + LDAP_FIRSTNAME_ATTR: str = Field(default="givenName") + LDAP_LASTNAME_ATTR: str = Field(default="sn") + LDAP_EMAIL_ATTR: str = Field(default="mail") + LDAP_CREATE_SUPERUSER: bool = Field(default=False) + + def validate_ldap_config(self) -> tuple[bool, str]: + """ + Validate that all required LDAP settings are configured. + + Returns: + Tuple of (is_valid, error_message) + """ + if not self.LDAP_ENABLED: + return True, "" + + required_fields = [ + "LDAP_SERVER_URI", + "LDAP_BIND_DN", + "LDAP_BIND_PASSWORD", + "LDAP_USER_BASE", + ] + + missing = [field for field in required_fields if not getattr(self, field)] + + if missing: + return False, f"LDAP_* config options must all be set if LDAP_ENABLED=True\nMissing: {', '.join(missing)}" + + return True, "" + + +# Singleton instance +LDAP_CONFIG = LDAPConfig() diff --git a/archivebox/core/settings.py b/archivebox/core/settings.py index 095db8ea..aee8d19d 100644 --- a/archivebox/core/settings.py +++ b/archivebox/core/settings.py @@ -99,16 +99,66 @@ AUTHENTICATION_BACKENDS = [ ] -# from ..plugins_auth.ldap.settings import LDAP_CONFIG +# LDAP Authentication Configuration +# Conditionally loaded if LDAP_ENABLED=True and django-auth-ldap is installed +try: + from archivebox.config.ldap import LDAP_CONFIG -# if LDAP_CONFIG.LDAP_ENABLED: -# AUTH_LDAP_BIND_DN = LDAP_CONFIG.LDAP_BIND_DN -# AUTH_LDAP_SERVER_URI = LDAP_CONFIG.LDAP_SERVER_URI -# AUTH_LDAP_BIND_PASSWORD = LDAP_CONFIG.LDAP_BIND_PASSWORD -# AUTH_LDAP_USER_ATTR_MAP = LDAP_CONFIG.LDAP_USER_ATTR_MAP -# AUTH_LDAP_USER_SEARCH = LDAP_CONFIG.AUTH_LDAP_USER_SEARCH + if LDAP_CONFIG.LDAP_ENABLED: + # Validate LDAP configuration + is_valid, error_msg = LDAP_CONFIG.validate_ldap_config() + if not is_valid: + from rich import print + print(f"[red][X] Error: {error_msg}[/red]") + raise ValueError(error_msg) -# AUTHENTICATION_BACKENDS = LDAP_CONFIG.AUTHENTICATION_BACKENDS + try: + # Try to import django-auth-ldap (will fail if not installed) + import django_auth_ldap + from django_auth_ldap.config import LDAPSearch + import ldap + + # Configure LDAP authentication + AUTH_LDAP_SERVER_URI = LDAP_CONFIG.LDAP_SERVER_URI + AUTH_LDAP_BIND_DN = LDAP_CONFIG.LDAP_BIND_DN + AUTH_LDAP_BIND_PASSWORD = LDAP_CONFIG.LDAP_BIND_PASSWORD + + # Configure user search + AUTH_LDAP_USER_SEARCH = LDAPSearch( + LDAP_CONFIG.LDAP_USER_BASE, + ldap.SCOPE_SUBTREE, + LDAP_CONFIG.LDAP_USER_FILTER, + ) + + # Map LDAP attributes to Django user model fields + AUTH_LDAP_USER_ATTR_MAP = { + "username": LDAP_CONFIG.LDAP_USERNAME_ATTR, + "first_name": LDAP_CONFIG.LDAP_FIRSTNAME_ATTR, + "last_name": LDAP_CONFIG.LDAP_LASTNAME_ATTR, + "email": LDAP_CONFIG.LDAP_EMAIL_ATTR, + } + + # Use custom LDAP backend that supports LDAP_CREATE_SUPERUSER + AUTHENTICATION_BACKENDS = [ + "archivebox.ldap.auth.ArchiveBoxLDAPBackend", + "django.contrib.auth.backends.RemoteUserBackend", + "django.contrib.auth.backends.ModelBackend", + ] + + except ImportError as e: + from rich import print + print("[red][X] Error: LDAP_ENABLED=True but required LDAP libraries are not installed![/red]") + print(f"[red] {e}[/red]") + print("[yellow] To install LDAP support, run:[/yellow]") + print("[yellow] pip install archivebox[ldap][/yellow]") + print("[yellow] Or manually:[/yellow]") + print("[yellow] apt install build-essential python3-dev libsasl2-dev libldap2-dev libssl-dev[/yellow]") + print("[yellow] pip install python-ldap django-auth-ldap[/yellow]") + raise + +except ImportError: + # archivebox.config.ldap not available (shouldn't happen but handle gracefully) + pass ################################################################################ ### Staticfile and Template Settings diff --git a/archivebox/ldap/__init__.py b/archivebox/ldap/__init__.py new file mode 100644 index 00000000..560f3460 --- /dev/null +++ b/archivebox/ldap/__init__.py @@ -0,0 +1,17 @@ +""" +LDAP authentication module for ArchiveBox. + +This module provides native LDAP authentication support using django-auth-ldap. +It only activates if: +1. LDAP_ENABLED=True in config +2. Required LDAP libraries (python-ldap, django-auth-ldap) are installed + +To install LDAP dependencies: + pip install archivebox[ldap] + +Or manually: + apt install build-essential python3-dev libsasl2-dev libldap2-dev libssl-dev + pip install python-ldap django-auth-ldap +""" + +__package__ = "archivebox.ldap" diff --git a/archivebox/ldap/apps.py b/archivebox/ldap/apps.py new file mode 100644 index 00000000..1d7fc44e --- /dev/null +++ b/archivebox/ldap/apps.py @@ -0,0 +1,13 @@ +"""Django app configuration for LDAP authentication.""" + +__package__ = "archivebox.ldap" + +from django.apps import AppConfig + + +class LDAPConfig(AppConfig): + """Django app config for LDAP authentication.""" + + default_auto_field = 'django.db.models.BigAutoField' + name = 'archivebox.ldap' + verbose_name = 'LDAP Authentication' diff --git a/archivebox/ldap/auth.py b/archivebox/ldap/auth.py new file mode 100644 index 00000000..3958ff09 --- /dev/null +++ b/archivebox/ldap/auth.py @@ -0,0 +1,49 @@ +""" +LDAP authentication backend for ArchiveBox. + +This module extends django-auth-ldap to support the LDAP_CREATE_SUPERUSER flag. +""" + +__package__ = "archivebox.ldap" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from django.contrib.auth.models import User + from django_auth_ldap.backend import LDAPBackend as BaseLDAPBackend +else: + try: + from django_auth_ldap.backend import LDAPBackend as BaseLDAPBackend + except ImportError: + # If django-auth-ldap is not installed, create a dummy base class + class BaseLDAPBackend: + """Dummy LDAP backend when django-auth-ldap is not installed.""" + pass + + +class ArchiveBoxLDAPBackend(BaseLDAPBackend): + """ + Custom LDAP authentication backend for ArchiveBox. + + Extends django-auth-ldap's LDAPBackend to support: + - LDAP_CREATE_SUPERUSER: Automatically grant superuser privileges to LDAP users + """ + + def authenticate_ldap_user(self, ldap_user, password): + """ + Authenticate using LDAP and optionally grant superuser privileges. + + This method is called by django-auth-ldap after successful LDAP authentication. + """ + from archivebox.config.ldap import LDAP_CONFIG + + user = super().authenticate_ldap_user(ldap_user, password) + + if user and LDAP_CONFIG.LDAP_CREATE_SUPERUSER: + # Grant superuser privileges to all LDAP-authenticated users + if not user.is_superuser: + user.is_superuser = True + user.is_staff = True + user.save() + + return user diff --git a/archivebox/tests/test_auth_ldap.py b/archivebox/tests/test_auth_ldap.py new file mode 100644 index 00000000..a56d29f7 --- /dev/null +++ b/archivebox/tests/test_auth_ldap.py @@ -0,0 +1,218 @@ +""" +LDAP authentication tests for ArchiveBox. + +Tests LDAP configuration, validation, and integration with Django. +Per CLAUDE.md: NO MOCKS, NO SKIPS - all tests use real code paths. +""" + +import os +import sys +import tempfile +import unittest +from pathlib import Path + + +class TestLDAPConfig(unittest.TestCase): + """Test LDAP configuration loading and validation.""" + + def test_ldap_config_defaults(self): + """Test that LDAP config loads with correct defaults.""" + from archivebox.config.ldap import LDAP_CONFIG + + # Check default values + self.assertFalse(LDAP_CONFIG.LDAP_ENABLED) + self.assertIsNone(LDAP_CONFIG.LDAP_SERVER_URI) + self.assertIsNone(LDAP_CONFIG.LDAP_BIND_DN) + self.assertIsNone(LDAP_CONFIG.LDAP_BIND_PASSWORD) + self.assertIsNone(LDAP_CONFIG.LDAP_USER_BASE) + self.assertEqual(LDAP_CONFIG.LDAP_USER_FILTER, "(uid=%(user)s)") + self.assertEqual(LDAP_CONFIG.LDAP_USERNAME_ATTR, "username") + self.assertEqual(LDAP_CONFIG.LDAP_FIRSTNAME_ATTR, "givenName") + self.assertEqual(LDAP_CONFIG.LDAP_LASTNAME_ATTR, "sn") + self.assertEqual(LDAP_CONFIG.LDAP_EMAIL_ATTR, "mail") + self.assertFalse(LDAP_CONFIG.LDAP_CREATE_SUPERUSER) + + def test_ldap_config_validation_disabled(self): + """Test that validation passes when LDAP is disabled.""" + from archivebox.config.ldap import LDAPConfig + + config = LDAPConfig(LDAP_ENABLED=False) + is_valid, error_msg = config.validate_ldap_config() + + self.assertTrue(is_valid) + self.assertEqual(error_msg, "") + + def test_ldap_config_validation_missing_fields(self): + """Test that validation fails when required fields are missing.""" + from archivebox.config.ldap import LDAPConfig + + # Enable LDAP but don't provide required fields + config = LDAPConfig(LDAP_ENABLED=True) + is_valid, error_msg = config.validate_ldap_config() + + self.assertFalse(is_valid) + self.assertIn("LDAP_* config options must all be set", error_msg) + self.assertIn("LDAP_SERVER_URI", error_msg) + self.assertIn("LDAP_BIND_DN", error_msg) + self.assertIn("LDAP_BIND_PASSWORD", error_msg) + self.assertIn("LDAP_USER_BASE", error_msg) + + def test_ldap_config_validation_complete(self): + """Test that validation passes when all required fields are provided.""" + from archivebox.config.ldap import LDAPConfig + + config = LDAPConfig( + LDAP_ENABLED=True, + LDAP_SERVER_URI="ldap://localhost:389", + LDAP_BIND_DN="cn=admin,dc=example,dc=com", + LDAP_BIND_PASSWORD="password", + LDAP_USER_BASE="ou=users,dc=example,dc=com", + ) + is_valid, error_msg = config.validate_ldap_config() + + self.assertTrue(is_valid) + self.assertEqual(error_msg, "") + + def test_ldap_config_in_get_config(self): + """Test that LDAP_CONFIG is included in get_CONFIG().""" + from archivebox.config import get_CONFIG + + all_config = get_CONFIG() + self.assertIn('LDAP_CONFIG', all_config) + self.assertEqual(all_config['LDAP_CONFIG'].__class__.__name__, 'LDAPConfig') + + +class TestLDAPIntegration(unittest.TestCase): + """Test LDAP integration with Django settings.""" + + def test_django_settings_without_ldap_enabled(self): + """Test that Django settings work correctly when LDAP is disabled.""" + # Import Django settings (LDAP_ENABLED should be False by default) + from django.conf import settings + + # Should have default authentication backends + self.assertIn("django.contrib.auth.backends.RemoteUserBackend", settings.AUTHENTICATION_BACKENDS) + self.assertIn("django.contrib.auth.backends.ModelBackend", settings.AUTHENTICATION_BACKENDS) + + # LDAP backend should not be present when disabled + ldap_backends = [b for b in settings.AUTHENTICATION_BACKENDS if 'ldap' in b.lower()] + self.assertEqual(len(ldap_backends), 0, "LDAP backend should not be present when LDAP_ENABLED=False") + + def test_django_settings_with_ldap_library_check(self): + """Test that Django settings check for LDAP libraries when enabled.""" + # Try to import django-auth-ldap to see if it's available + try: + import django_auth_ldap + import ldap + ldap_available = True + except ImportError: + ldap_available = False + + # If LDAP libraries are not available, settings should handle gracefully + if not ldap_available: + # Settings should have loaded without LDAP backend + from django.conf import settings + ldap_backends = [b for b in settings.AUTHENTICATION_BACKENDS if 'ldap' in b.lower()] + self.assertEqual(len(ldap_backends), 0, "LDAP backend should not be present when libraries unavailable") + + +class TestLDAPAuthBackend(unittest.TestCase): + """Test custom LDAP authentication backend.""" + + def test_ldap_backend_class_exists(self): + """Test that ArchiveBoxLDAPBackend class is defined.""" + from archivebox.ldap.auth import ArchiveBoxLDAPBackend + + self.assertTrue(hasattr(ArchiveBoxLDAPBackend, 'authenticate_ldap_user')) + + def test_ldap_backend_inherits_correctly(self): + """Test that ArchiveBoxLDAPBackend has correct inheritance.""" + from archivebox.ldap.auth import ArchiveBoxLDAPBackend + + # Should have authenticate_ldap_user method (from base or overridden) + self.assertTrue(callable(getattr(ArchiveBoxLDAPBackend, 'authenticate_ldap_user', None))) + + +class TestArchiveBoxWithLDAP(unittest.TestCase): + """Test ArchiveBox commands with LDAP configuration.""" + + def setUp(self): + """Set up test environment.""" + self.work_dir = tempfile.mkdtemp(prefix='archivebox-ldap-test-') + + def test_archivebox_init_without_ldap(self): + """Test that archivebox init works without LDAP enabled.""" + import subprocess + + # Run archivebox init + result = subprocess.run( + [sys.executable, '-m', 'archivebox', 'init'], + cwd=self.work_dir, + capture_output=True, + timeout=45, + env={ + **os.environ, + 'DATA_DIR': self.work_dir, + 'LDAP_ENABLED': 'False', + } + ) + + # Should succeed + self.assertEqual(result.returncode, 0, f"archivebox init failed: {result.stderr.decode()}") + + def test_archivebox_version_with_ldap_config(self): + """Test that archivebox version works with LDAP config set.""" + import subprocess + + # Run archivebox version with LDAP config env vars + result = subprocess.run( + [sys.executable, '-m', 'archivebox', 'version'], + capture_output=True, + timeout=10, + env={ + **os.environ, + 'LDAP_ENABLED': 'False', + 'LDAP_SERVER_URI': 'ldap://localhost:389', + } + ) + + # Should succeed + self.assertEqual(result.returncode, 0, f"archivebox version failed: {result.stderr.decode()}") + + +class TestLDAPConfigValidationInArchiveBox(unittest.TestCase): + """Test LDAP config validation when running ArchiveBox commands.""" + + def setUp(self): + """Set up test environment.""" + self.work_dir = tempfile.mkdtemp(prefix='archivebox-ldap-validation-') + + def test_archivebox_init_with_incomplete_ldap_config(self): + """Test that archivebox init fails with helpful error when LDAP config is incomplete.""" + import subprocess + + # Run archivebox init with LDAP enabled but missing required fields + result = subprocess.run( + [sys.executable, '-m', 'archivebox', 'init'], + cwd=self.work_dir, + capture_output=True, + timeout=45, + env={ + **os.environ, + 'DATA_DIR': self.work_dir, + 'LDAP_ENABLED': 'True', + # Missing: LDAP_SERVER_URI, LDAP_BIND_DN, etc. + } + ) + + # Should fail with validation error + self.assertNotEqual(result.returncode, 0, "Should fail with incomplete LDAP config") + + # Check error message + stderr = result.stderr.decode() + self.assertIn("LDAP_* config options must all be set", stderr, + f"Expected validation error message in: {stderr}") + + +if __name__ == '__main__': + unittest.main()