iflow-mcp_alpadalar-active-directory-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- active_directory_mcp/__init__.py +15 -0
- active_directory_mcp/config/__init__.py +21 -0
- active_directory_mcp/config/loader.py +101 -0
- active_directory_mcp/config/models.py +101 -0
- active_directory_mcp/core/__init__.py +6 -0
- active_directory_mcp/core/ldap_manager.py +423 -0
- active_directory_mcp/core/logging.py +103 -0
- active_directory_mcp/server.py +508 -0
- active_directory_mcp/server_http.py +461 -0
- active_directory_mcp/tools/__init__.py +17 -0
- active_directory_mcp/tools/base.py +198 -0
- active_directory_mcp/tools/computer.py +777 -0
- active_directory_mcp/tools/definitions.py +421 -0
- active_directory_mcp/tools/group.py +626 -0
- active_directory_mcp/tools/organizational_unit.py +813 -0
- active_directory_mcp/tools/security.py +888 -0
- active_directory_mcp/tools/user.py +650 -0
- iflow_mcp_alpadalar_active_directory_mcp-0.1.0.dist-info/METADATA +620 -0
- iflow_mcp_alpadalar_active_directory_mcp-0.1.0.dist-info/RECORD +23 -0
- iflow_mcp_alpadalar_active_directory_mcp-0.1.0.dist-info/WHEEL +5 -0
- iflow_mcp_alpadalar_active_directory_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_alpadalar_active_directory_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- iflow_mcp_alpadalar_active_directory_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ActiveDirectoryMCP - Model Context Protocol server for Active Directory management.
|
|
3
|
+
|
|
4
|
+
This package provides a comprehensive MCP server for interacting with Active Directory
|
|
5
|
+
through LDAP protocol, offering tools for user management, group operations,
|
|
6
|
+
organizational unit management, and security operations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__author__ = "Alperen Adalar"
|
|
11
|
+
__email__ = "alp.adalar@gmail.com"
|
|
12
|
+
|
|
13
|
+
from .server import ActiveDirectoryMCPServer
|
|
14
|
+
|
|
15
|
+
__all__ = ["ActiveDirectoryMCPServer"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Configuration module for Active Directory MCP."""
|
|
2
|
+
|
|
3
|
+
from .loader import load_config
|
|
4
|
+
from .models import (
|
|
5
|
+
ActiveDirectoryConfig,
|
|
6
|
+
OrganizationalUnitsConfig,
|
|
7
|
+
SecurityConfig,
|
|
8
|
+
LoggingConfig,
|
|
9
|
+
PerformanceConfig,
|
|
10
|
+
Config,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"load_config",
|
|
15
|
+
"ActiveDirectoryConfig",
|
|
16
|
+
"OrganizationalUnitsConfig",
|
|
17
|
+
"SecurityConfig",
|
|
18
|
+
"LoggingConfig",
|
|
19
|
+
"PerformanceConfig",
|
|
20
|
+
"Config",
|
|
21
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Configuration loader for Active Directory MCP."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from .models import Config
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_config(config_path: Optional[str] = None) -> Config:
|
|
15
|
+
"""
|
|
16
|
+
Load configuration from JSON file.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
config_path: Path to configuration file. If None, uses AD_MCP_CONFIG
|
|
20
|
+
environment variable.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Config: Loaded and validated configuration
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
FileNotFoundError: If config file doesn't exist
|
|
27
|
+
ValueError: If config is invalid
|
|
28
|
+
json.JSONDecodeError: If config file is not valid JSON
|
|
29
|
+
"""
|
|
30
|
+
# Determine config file path
|
|
31
|
+
if config_path is None:
|
|
32
|
+
config_path = os.getenv("AD_MCP_CONFIG")
|
|
33
|
+
if not config_path:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
"No configuration file specified. Either provide config_path or set AD_MCP_CONFIG environment variable."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
config_file = Path(config_path)
|
|
39
|
+
if not config_file.exists():
|
|
40
|
+
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
|
41
|
+
|
|
42
|
+
logger.info(f"Loading configuration from: {config_path}")
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
with open(config_file, 'r', encoding='utf-8') as f:
|
|
46
|
+
config_data = json.load(f)
|
|
47
|
+
|
|
48
|
+
# Validate and create config object
|
|
49
|
+
config = Config(**config_data)
|
|
50
|
+
logger.info("Configuration loaded successfully")
|
|
51
|
+
|
|
52
|
+
# Log configuration summary (without sensitive data)
|
|
53
|
+
logger.debug(f"AD Server: {config.active_directory.server}")
|
|
54
|
+
logger.debug(f"Domain: {config.active_directory.domain}")
|
|
55
|
+
logger.debug(f"Base DN: {config.active_directory.base_dn}")
|
|
56
|
+
logger.debug(f"SSL Enabled: {config.active_directory.use_ssl}")
|
|
57
|
+
|
|
58
|
+
return config
|
|
59
|
+
|
|
60
|
+
except json.JSONDecodeError as e:
|
|
61
|
+
logger.error(f"Invalid JSON in configuration file: {e}")
|
|
62
|
+
raise
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.error(f"Error loading configuration: {e}")
|
|
65
|
+
raise
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate_config(config: Config) -> None:
|
|
69
|
+
"""
|
|
70
|
+
Perform additional validation on configuration.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
config: Configuration to validate
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValueError: If configuration is invalid
|
|
77
|
+
"""
|
|
78
|
+
# Check if required OUs are under base DN
|
|
79
|
+
base_dn = config.active_directory.base_dn.lower()
|
|
80
|
+
|
|
81
|
+
ous = [
|
|
82
|
+
config.organizational_units.users_ou,
|
|
83
|
+
config.organizational_units.groups_ou,
|
|
84
|
+
config.organizational_units.computers_ou,
|
|
85
|
+
config.organizational_units.service_accounts_ou,
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
for ou in ous:
|
|
89
|
+
if not ou.lower().endswith(base_dn):
|
|
90
|
+
logger.warning(f"OU {ou} is not under base DN {config.active_directory.base_dn}")
|
|
91
|
+
|
|
92
|
+
# Validate bind DN
|
|
93
|
+
if not config.active_directory.bind_dn.lower().endswith(base_dn):
|
|
94
|
+
logger.warning(f"Bind DN {config.active_directory.bind_dn} is not under base DN")
|
|
95
|
+
|
|
96
|
+
# Check SSL configuration
|
|
97
|
+
if config.active_directory.use_ssl and config.security.enable_tls:
|
|
98
|
+
if not config.active_directory.server.startswith('ldaps://'):
|
|
99
|
+
logger.warning("SSL enabled but server URL doesn't use ldaps://")
|
|
100
|
+
|
|
101
|
+
logger.info("Configuration validation completed")
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Configuration models for Active Directory MCP."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
from pydantic import BaseModel, Field, field_validator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ActiveDirectoryConfig(BaseModel):
|
|
8
|
+
"""Active Directory connection configuration."""
|
|
9
|
+
|
|
10
|
+
server: str = Field(..., description="Primary LDAP server URL")
|
|
11
|
+
server_pool: Optional[List[str]] = Field(default=None, description="Additional LDAP servers for redundancy")
|
|
12
|
+
use_ssl: bool = Field(default=True, description="Use SSL/TLS connection")
|
|
13
|
+
ssl_port: int = Field(default=636, description="SSL port for LDAP")
|
|
14
|
+
domain: str = Field(..., description="Active Directory domain")
|
|
15
|
+
base_dn: str = Field(..., description="Base Distinguished Name")
|
|
16
|
+
bind_dn: str = Field(..., description="Service account DN for binding")
|
|
17
|
+
password: str = Field(..., description="Service account password")
|
|
18
|
+
timeout: int = Field(default=30, description="Connection timeout in seconds")
|
|
19
|
+
auto_bind: bool = Field(default=True, description="Automatically bind on connection")
|
|
20
|
+
receive_timeout: int = Field(default=10, description="Receive timeout in seconds")
|
|
21
|
+
|
|
22
|
+
@field_validator('server')
|
|
23
|
+
@classmethod
|
|
24
|
+
def validate_server(cls, v):
|
|
25
|
+
"""Validate server URL format."""
|
|
26
|
+
if not v.startswith(('ldap://', 'ldaps://')):
|
|
27
|
+
raise ValueError('Server must start with ldap:// or ldaps://')
|
|
28
|
+
return v
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OrganizationalUnitsConfig(BaseModel):
|
|
32
|
+
"""Organizational Units configuration."""
|
|
33
|
+
|
|
34
|
+
users_ou: str = Field(..., description="Users organizational unit DN")
|
|
35
|
+
groups_ou: str = Field(..., description="Groups organizational unit DN")
|
|
36
|
+
computers_ou: str = Field(..., description="Computers organizational unit DN")
|
|
37
|
+
service_accounts_ou: str = Field(..., description="Service accounts organizational unit DN")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SecurityConfig(BaseModel):
|
|
41
|
+
"""Security configuration for LDAP connections."""
|
|
42
|
+
|
|
43
|
+
enable_tls: bool = Field(default=True, description="Enable TLS encryption")
|
|
44
|
+
validate_certificate: bool = Field(default=True, description="Validate server certificate")
|
|
45
|
+
ca_cert_file: Optional[str] = Field(default=None, description="CA certificate file path")
|
|
46
|
+
require_secure_connection: bool = Field(default=True, description="Require secure connection")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LoggingConfig(BaseModel):
|
|
50
|
+
"""Logging configuration."""
|
|
51
|
+
|
|
52
|
+
level: str = Field(default="INFO", description="Logging level")
|
|
53
|
+
format: str = Field(
|
|
54
|
+
default="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
55
|
+
description="Log message format"
|
|
56
|
+
)
|
|
57
|
+
file: Optional[str] = Field(default=None, description="Log file path")
|
|
58
|
+
|
|
59
|
+
@field_validator('level')
|
|
60
|
+
@classmethod
|
|
61
|
+
def validate_level(cls, v):
|
|
62
|
+
"""Validate logging level."""
|
|
63
|
+
valid_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
|
|
64
|
+
if v.upper() not in valid_levels:
|
|
65
|
+
raise ValueError(f'Level must be one of: {valid_levels}')
|
|
66
|
+
return v.upper()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class PerformanceConfig(BaseModel):
|
|
70
|
+
"""Performance configuration."""
|
|
71
|
+
|
|
72
|
+
connection_pool_size: int = Field(default=10, description="Connection pool size")
|
|
73
|
+
max_retries: int = Field(default=3, description="Maximum connection retries")
|
|
74
|
+
retry_delay: float = Field(default=1.0, description="Retry delay in seconds")
|
|
75
|
+
page_size: int = Field(default=1000, description="LDAP search page size")
|
|
76
|
+
|
|
77
|
+
@field_validator('connection_pool_size', 'max_retries', 'page_size')
|
|
78
|
+
@classmethod
|
|
79
|
+
def validate_positive_int(cls, v):
|
|
80
|
+
"""Validate positive integers."""
|
|
81
|
+
if v <= 0:
|
|
82
|
+
raise ValueError('Value must be positive')
|
|
83
|
+
return v
|
|
84
|
+
|
|
85
|
+
@field_validator('retry_delay')
|
|
86
|
+
@classmethod
|
|
87
|
+
def validate_positive_float(cls, v):
|
|
88
|
+
"""Validate positive float."""
|
|
89
|
+
if v <= 0:
|
|
90
|
+
raise ValueError('Retry delay must be positive')
|
|
91
|
+
return v
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Config(BaseModel):
|
|
95
|
+
"""Main configuration class."""
|
|
96
|
+
|
|
97
|
+
active_directory: ActiveDirectoryConfig
|
|
98
|
+
organizational_units: OrganizationalUnitsConfig
|
|
99
|
+
security: SecurityConfig = Field(default_factory=SecurityConfig)
|
|
100
|
+
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
|
101
|
+
performance: PerformanceConfig = Field(default_factory=PerformanceConfig)
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""LDAP connection manager for Active Directory."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import ssl
|
|
5
|
+
import time
|
|
6
|
+
from typing import Optional, List, Dict, Any, Union
|
|
7
|
+
from threading import Lock
|
|
8
|
+
|
|
9
|
+
import ldap3
|
|
10
|
+
from ldap3 import Server, Connection, ALL, SUBTREE, ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES
|
|
11
|
+
from ldap3.core.exceptions import LDAPException, LDAPBindError, LDAPSocketOpenError
|
|
12
|
+
|
|
13
|
+
from ..config.models import ActiveDirectoryConfig, SecurityConfig, PerformanceConfig
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LDAPManager:
|
|
19
|
+
"""
|
|
20
|
+
LDAP connection manager for Active Directory operations.
|
|
21
|
+
|
|
22
|
+
Provides connection pooling, automatic reconnection, and error handling
|
|
23
|
+
for LDAP operations against Active Directory.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self,
|
|
27
|
+
ad_config: ActiveDirectoryConfig,
|
|
28
|
+
security_config: SecurityConfig,
|
|
29
|
+
performance_config: PerformanceConfig):
|
|
30
|
+
"""
|
|
31
|
+
Initialize LDAP manager.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
ad_config: Active Directory configuration
|
|
35
|
+
security_config: Security configuration
|
|
36
|
+
performance_config: Performance configuration
|
|
37
|
+
"""
|
|
38
|
+
self.ad_config = ad_config
|
|
39
|
+
self.security_config = security_config
|
|
40
|
+
self.performance_config = performance_config
|
|
41
|
+
|
|
42
|
+
self._connection: Optional[Connection] = None
|
|
43
|
+
self._server_pool: Optional[List[Server]] = None
|
|
44
|
+
self._lock = Lock()
|
|
45
|
+
|
|
46
|
+
self._setup_servers()
|
|
47
|
+
|
|
48
|
+
def _setup_servers(self) -> None:
|
|
49
|
+
"""Setup LDAP servers and server pool."""
|
|
50
|
+
try:
|
|
51
|
+
# Setup TLS configuration
|
|
52
|
+
tls_config = None
|
|
53
|
+
if self.security_config.enable_tls:
|
|
54
|
+
tls_config = ldap3.Tls(
|
|
55
|
+
validate=ssl.CERT_REQUIRED if self.security_config.validate_certificate else ssl.CERT_NONE,
|
|
56
|
+
ca_certs_file=self.security_config.ca_cert_file
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Create primary server
|
|
60
|
+
primary_server = Server(
|
|
61
|
+
self.ad_config.server,
|
|
62
|
+
get_info=ALL,
|
|
63
|
+
tls=tls_config,
|
|
64
|
+
connect_timeout=self.ad_config.timeout
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
servers = [primary_server]
|
|
68
|
+
|
|
69
|
+
# Add additional servers from pool
|
|
70
|
+
if self.ad_config.server_pool:
|
|
71
|
+
for server_url in self.ad_config.server_pool:
|
|
72
|
+
server = Server(
|
|
73
|
+
server_url,
|
|
74
|
+
get_info=ALL,
|
|
75
|
+
tls=tls_config,
|
|
76
|
+
connect_timeout=self.ad_config.timeout
|
|
77
|
+
)
|
|
78
|
+
servers.append(server)
|
|
79
|
+
|
|
80
|
+
# Create server pool for failover
|
|
81
|
+
self._server_pool = servers
|
|
82
|
+
logger.info(f"Configured {len(servers)} LDAP servers")
|
|
83
|
+
|
|
84
|
+
except Exception as e:
|
|
85
|
+
logger.error(f"Error setting up LDAP servers: {e}")
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
def connect(self) -> Connection:
|
|
89
|
+
"""
|
|
90
|
+
Establish LDAP connection with retry logic.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Connection: Active LDAP connection
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
LDAPException: If connection fails after all retries
|
|
97
|
+
"""
|
|
98
|
+
with self._lock:
|
|
99
|
+
if self._connection and self._connection.bound:
|
|
100
|
+
return self._connection
|
|
101
|
+
|
|
102
|
+
last_error = None
|
|
103
|
+
|
|
104
|
+
for attempt in range(self.performance_config.max_retries):
|
|
105
|
+
try:
|
|
106
|
+
# Try each server in the pool
|
|
107
|
+
for server in self._server_pool:
|
|
108
|
+
try:
|
|
109
|
+
logger.debug(f"Attempting connection to {server.host}:{server.port}")
|
|
110
|
+
|
|
111
|
+
connection = Connection(
|
|
112
|
+
server,
|
|
113
|
+
user=self.ad_config.bind_dn,
|
|
114
|
+
password=self.ad_config.password,
|
|
115
|
+
auto_bind=self.ad_config.auto_bind,
|
|
116
|
+
receive_timeout=self.ad_config.receive_timeout,
|
|
117
|
+
authentication=ldap3.SIMPLE,
|
|
118
|
+
check_names=True,
|
|
119
|
+
raise_exceptions=True
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Test the connection
|
|
123
|
+
if connection.bind():
|
|
124
|
+
self._connection = connection
|
|
125
|
+
logger.info(f"Successfully connected to {server.host}:{server.port}")
|
|
126
|
+
return connection
|
|
127
|
+
else:
|
|
128
|
+
logger.warning(f"Failed to bind to {server.host}:{server.port}")
|
|
129
|
+
|
|
130
|
+
except (LDAPSocketOpenError, LDAPBindError) as e:
|
|
131
|
+
logger.warning(f"Connection failed to {server.host}:{server.port}: {e}")
|
|
132
|
+
last_error = e
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
# If we get here, all servers failed for this attempt
|
|
136
|
+
if attempt < self.performance_config.max_retries - 1:
|
|
137
|
+
logger.info(f"Retry {attempt + 1}/{self.performance_config.max_retries} after {self.performance_config.retry_delay}s")
|
|
138
|
+
time.sleep(self.performance_config.retry_delay)
|
|
139
|
+
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.error(f"Unexpected error during connection attempt {attempt + 1}: {e}")
|
|
142
|
+
last_error = e
|
|
143
|
+
|
|
144
|
+
if attempt < self.performance_config.max_retries - 1:
|
|
145
|
+
time.sleep(self.performance_config.retry_delay)
|
|
146
|
+
|
|
147
|
+
# All attempts failed
|
|
148
|
+
error_msg = f"Failed to connect to any LDAP server after {self.performance_config.max_retries} attempts"
|
|
149
|
+
if last_error:
|
|
150
|
+
error_msg += f". Last error: {last_error}"
|
|
151
|
+
|
|
152
|
+
logger.error(error_msg)
|
|
153
|
+
raise LDAPException(error_msg)
|
|
154
|
+
|
|
155
|
+
def disconnect(self) -> None:
|
|
156
|
+
"""Disconnect from LDAP server."""
|
|
157
|
+
with self._lock:
|
|
158
|
+
if self._connection:
|
|
159
|
+
try:
|
|
160
|
+
self._connection.unbind()
|
|
161
|
+
logger.info("Disconnected from LDAP server")
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.warning(f"Error during disconnect: {e}")
|
|
164
|
+
finally:
|
|
165
|
+
self._connection = None
|
|
166
|
+
|
|
167
|
+
def search(self,
|
|
168
|
+
search_base: str,
|
|
169
|
+
search_filter: str,
|
|
170
|
+
attributes: Union[List[str], str] = ALL_ATTRIBUTES,
|
|
171
|
+
search_scope: str = SUBTREE,
|
|
172
|
+
size_limit: int = 0) -> List[Dict[str, Any]]:
|
|
173
|
+
"""
|
|
174
|
+
Perform LDAP search operation.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
search_base: Base DN for search
|
|
178
|
+
search_filter: LDAP filter string
|
|
179
|
+
attributes: Attributes to retrieve
|
|
180
|
+
search_scope: Search scope (SUBTREE, ONELEVEL, BASE)
|
|
181
|
+
size_limit: Maximum number of results (0 = no limit)
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
List of LDAP entries as dictionaries
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
LDAPException: If search fails
|
|
188
|
+
"""
|
|
189
|
+
connection = self.connect()
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
logger.debug(f"Searching: base={search_base}, filter={search_filter}")
|
|
193
|
+
|
|
194
|
+
# Perform paged search for large result sets
|
|
195
|
+
paged_size = min(self.performance_config.page_size, size_limit) if size_limit > 0 else self.performance_config.page_size
|
|
196
|
+
|
|
197
|
+
entries = []
|
|
198
|
+
cookie = None
|
|
199
|
+
|
|
200
|
+
while True:
|
|
201
|
+
success = connection.search(
|
|
202
|
+
search_base=search_base,
|
|
203
|
+
search_filter=search_filter,
|
|
204
|
+
search_scope=search_scope,
|
|
205
|
+
attributes=attributes,
|
|
206
|
+
paged_size=paged_size,
|
|
207
|
+
paged_cookie=cookie
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if not success:
|
|
211
|
+
logger.error(f"Search failed: {connection.result}")
|
|
212
|
+
raise LDAPException(f"Search failed: {connection.result}")
|
|
213
|
+
|
|
214
|
+
# Add entries to results
|
|
215
|
+
for entry in connection.entries:
|
|
216
|
+
entry_dict = {
|
|
217
|
+
'dn': entry.entry_dn,
|
|
218
|
+
'attributes': {}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for attr_name in entry.entry_attributes:
|
|
222
|
+
attr_value = getattr(entry, attr_name)
|
|
223
|
+
if hasattr(attr_value, 'value'):
|
|
224
|
+
entry_dict['attributes'][attr_name] = attr_value.value
|
|
225
|
+
else:
|
|
226
|
+
entry_dict['attributes'][attr_name] = str(attr_value)
|
|
227
|
+
|
|
228
|
+
entries.append(entry_dict)
|
|
229
|
+
|
|
230
|
+
# Check size limit
|
|
231
|
+
if size_limit > 0 and len(entries) >= size_limit:
|
|
232
|
+
logger.debug(f"Size limit reached: {size_limit}")
|
|
233
|
+
return entries[:size_limit]
|
|
234
|
+
|
|
235
|
+
# Check for more pages
|
|
236
|
+
cookie = connection.result.get('controls', {}).get('1.2.840.113556.1.4.319', {}).get('value', {}).get('cookie')
|
|
237
|
+
if not cookie:
|
|
238
|
+
break
|
|
239
|
+
|
|
240
|
+
logger.debug(f"Search returned {len(entries)} entries")
|
|
241
|
+
return entries
|
|
242
|
+
|
|
243
|
+
except Exception as e:
|
|
244
|
+
logger.error(f"Search error: {e}")
|
|
245
|
+
raise
|
|
246
|
+
|
|
247
|
+
def add(self, dn: str, attributes: Dict[str, Any]) -> bool:
|
|
248
|
+
"""
|
|
249
|
+
Add LDAP entry.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
dn: Distinguished name of new entry
|
|
253
|
+
attributes: Entry attributes
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
True if successful
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
LDAPException: If operation fails
|
|
260
|
+
"""
|
|
261
|
+
connection = self.connect()
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
logger.debug(f"Adding entry: {dn}")
|
|
265
|
+
|
|
266
|
+
success = connection.add(dn, attributes=attributes)
|
|
267
|
+
|
|
268
|
+
if success:
|
|
269
|
+
logger.info(f"Successfully added entry: {dn}")
|
|
270
|
+
return True
|
|
271
|
+
else:
|
|
272
|
+
logger.error(f"Failed to add entry {dn}: {connection.result}")
|
|
273
|
+
raise LDAPException(f"Add operation failed: {connection.result}")
|
|
274
|
+
|
|
275
|
+
except Exception as e:
|
|
276
|
+
logger.error(f"Add error for {dn}: {e}")
|
|
277
|
+
raise
|
|
278
|
+
|
|
279
|
+
def modify(self, dn: str, changes: Dict[str, Any]) -> bool:
|
|
280
|
+
"""
|
|
281
|
+
Modify LDAP entry.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
dn: Distinguished name of entry to modify
|
|
285
|
+
changes: Dictionary of changes to apply
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
True if successful
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
LDAPException: If operation fails
|
|
292
|
+
"""
|
|
293
|
+
connection = self.connect()
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
logger.debug(f"Modifying entry: {dn}")
|
|
297
|
+
|
|
298
|
+
success = connection.modify(dn, changes)
|
|
299
|
+
|
|
300
|
+
if success:
|
|
301
|
+
logger.info(f"Successfully modified entry: {dn}")
|
|
302
|
+
return True
|
|
303
|
+
else:
|
|
304
|
+
logger.error(f"Failed to modify entry {dn}: {connection.result}")
|
|
305
|
+
raise LDAPException(f"Modify operation failed: {connection.result}")
|
|
306
|
+
|
|
307
|
+
except Exception as e:
|
|
308
|
+
logger.error(f"Modify error for {dn}: {e}")
|
|
309
|
+
raise
|
|
310
|
+
|
|
311
|
+
def delete(self, dn: str) -> bool:
|
|
312
|
+
"""
|
|
313
|
+
Delete LDAP entry.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
dn: Distinguished name of entry to delete
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
True if successful
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
LDAPException: If operation fails
|
|
323
|
+
"""
|
|
324
|
+
connection = self.connect()
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
logger.debug(f"Deleting entry: {dn}")
|
|
328
|
+
|
|
329
|
+
success = connection.delete(dn)
|
|
330
|
+
|
|
331
|
+
if success:
|
|
332
|
+
logger.info(f"Successfully deleted entry: {dn}")
|
|
333
|
+
return True
|
|
334
|
+
else:
|
|
335
|
+
logger.error(f"Failed to delete entry {dn}: {connection.result}")
|
|
336
|
+
raise LDAPException(f"Delete operation failed: {connection.result}")
|
|
337
|
+
|
|
338
|
+
except Exception as e:
|
|
339
|
+
logger.error(f"Delete error for {dn}: {e}")
|
|
340
|
+
raise
|
|
341
|
+
|
|
342
|
+
def move(self, dn: str, new_parent: str) -> bool:
|
|
343
|
+
"""
|
|
344
|
+
Move LDAP entry to new parent.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
dn: Distinguished name of entry to move
|
|
348
|
+
new_parent: New parent DN
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
True if successful
|
|
352
|
+
|
|
353
|
+
Raises:
|
|
354
|
+
LDAPException: If operation fails
|
|
355
|
+
"""
|
|
356
|
+
connection = self.connect()
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
logger.debug(f"Moving entry {dn} to {new_parent}")
|
|
360
|
+
|
|
361
|
+
success = connection.modify_dn(dn, new_superior=new_parent)
|
|
362
|
+
|
|
363
|
+
if success:
|
|
364
|
+
logger.info(f"Successfully moved entry {dn} to {new_parent}")
|
|
365
|
+
return True
|
|
366
|
+
else:
|
|
367
|
+
logger.error(f"Failed to move entry {dn}: {connection.result}")
|
|
368
|
+
raise LDAPException(f"Move operation failed: {connection.result}")
|
|
369
|
+
|
|
370
|
+
except Exception as e:
|
|
371
|
+
logger.error(f"Move error for {dn}: {e}")
|
|
372
|
+
raise
|
|
373
|
+
|
|
374
|
+
def test_connection(self) -> Dict[str, Any]:
|
|
375
|
+
"""
|
|
376
|
+
Test LDAP connection and return server information.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Dictionary with connection test results
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
connection = self.connect()
|
|
383
|
+
|
|
384
|
+
# Get server info
|
|
385
|
+
server_info = {
|
|
386
|
+
'connected': True,
|
|
387
|
+
'server': connection.server.host,
|
|
388
|
+
'port': connection.server.port,
|
|
389
|
+
'ssl': connection.server.ssl,
|
|
390
|
+
'bound': connection.bound,
|
|
391
|
+
'user': connection.user
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
# Try a simple search to test functionality
|
|
395
|
+
try:
|
|
396
|
+
connection.search(
|
|
397
|
+
search_base=self.ad_config.base_dn,
|
|
398
|
+
search_filter='(objectClass=*)',
|
|
399
|
+
search_scope=ldap3.BASE,
|
|
400
|
+
attributes=['namingContexts']
|
|
401
|
+
)
|
|
402
|
+
server_info['search_test'] = True
|
|
403
|
+
except Exception as e:
|
|
404
|
+
server_info['search_test'] = False
|
|
405
|
+
server_info['search_error'] = str(e)
|
|
406
|
+
|
|
407
|
+
logger.info("Connection test successful")
|
|
408
|
+
return server_info
|
|
409
|
+
|
|
410
|
+
except Exception as e:
|
|
411
|
+
logger.error(f"Connection test failed: {e}")
|
|
412
|
+
return {
|
|
413
|
+
'connected': False,
|
|
414
|
+
'error': str(e)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
def __enter__(self):
|
|
418
|
+
"""Context manager entry."""
|
|
419
|
+
return self
|
|
420
|
+
|
|
421
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
422
|
+
"""Context manager exit."""
|
|
423
|
+
self.disconnect()
|