lldap-py 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.
- lldap/__init__.py +76 -0
- lldap/client.py +213 -0
- lldap/config.py +46 -0
- lldap/exceptions.py +31 -0
- lldap/groups.py +160 -0
- lldap/models.py +27 -0
- lldap/users.py +204 -0
- lldap_py-0.1.0.dist-info/METADATA +61 -0
- lldap_py-0.1.0.dist-info/RECORD +12 -0
- lldap_py-0.1.0.dist-info/WHEEL +5 -0
- lldap_py-0.1.0.dist-info/licenses/LICENSE +21 -0
- lldap_py-0.1.0.dist-info/top_level.txt +1 -0
lldap/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""LLDAP-py - Simple Python interface for managing LLDAP servers."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .config import Config
|
|
6
|
+
from .client import LLDAPClient
|
|
7
|
+
from .users import UserManager
|
|
8
|
+
from .groups import GroupManager
|
|
9
|
+
from .exceptions import LLDAPError, AuthenticationError, ConnectionError, GraphQLError, ValidationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LLDAPManager(UserManager, GroupManager):
|
|
13
|
+
"""Simplified LLDAP manager combining user and group management.
|
|
14
|
+
|
|
15
|
+
This is the main interface for interacting with LLDAP servers.
|
|
16
|
+
Pass connection values in the constructor and use methods for operations.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
http_url: str, # (http(s)://<host>:<port>)
|
|
22
|
+
username: str = None,
|
|
23
|
+
password: str = None,
|
|
24
|
+
token: str = None,
|
|
25
|
+
refresh_token: str = None,
|
|
26
|
+
):
|
|
27
|
+
"""Initialize LLDAP Manager with connection details.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
http_url: HTTP URL of LLDAP server (e.g., "http://localhost:17170")
|
|
31
|
+
username: Admin username (default: "admin")
|
|
32
|
+
password: Admin password
|
|
33
|
+
token: Authentication token (if already authenticated)
|
|
34
|
+
refresh_token: Refresh token for token renewal
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
AuthenticationError: If connection/authentication fails
|
|
38
|
+
"""
|
|
39
|
+
self.config = Config(
|
|
40
|
+
http_url=http_url,
|
|
41
|
+
username=username,
|
|
42
|
+
password=password,
|
|
43
|
+
token=token,
|
|
44
|
+
refresh_token=refresh_token,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
self.config.validate()
|
|
49
|
+
except LLDAPError:
|
|
50
|
+
raise
|
|
51
|
+
|
|
52
|
+
self.client = LLDAPClient(self.config)
|
|
53
|
+
|
|
54
|
+
# Authenticate on initialization
|
|
55
|
+
try:
|
|
56
|
+
self.client.authenticate()
|
|
57
|
+
except (AuthenticationError, ConnectionError):
|
|
58
|
+
raise
|
|
59
|
+
|
|
60
|
+
def close(self):
|
|
61
|
+
"""Close the session."""
|
|
62
|
+
if hasattr(self.client, 'session'):
|
|
63
|
+
self.client.session.close()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"LLDAPManager",
|
|
68
|
+
"LLDAPClient",
|
|
69
|
+
"LLDAPError",
|
|
70
|
+
"UserManager",
|
|
71
|
+
"GroupManager",
|
|
72
|
+
"AuthenticationError",
|
|
73
|
+
"ConnectionError",
|
|
74
|
+
"ValidationError",
|
|
75
|
+
"GraphQLError",
|
|
76
|
+
]
|
lldap/client.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Optional, Dict, Any, Tuple
|
|
3
|
+
import requests
|
|
4
|
+
from .config import Config
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
ConnectionError,
|
|
8
|
+
GraphQLError,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LLDAPClient:
|
|
13
|
+
"""Client for interacting with LLDAP server via GraphQL API."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, config: Config):
|
|
16
|
+
"""Initialize LLDAP client.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
config: Configuration object
|
|
20
|
+
"""
|
|
21
|
+
self.config = config
|
|
22
|
+
self.session = requests.Session()
|
|
23
|
+
self._authenticated = False
|
|
24
|
+
|
|
25
|
+
def authenticate(self) -> Tuple[str, str]:
|
|
26
|
+
"""Authenticate and get tokens.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Tuple of (token, refresh_token)
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
AuthenticationError: If authentication fails
|
|
33
|
+
ConnectionError: If connection fails
|
|
34
|
+
"""
|
|
35
|
+
if self.config.token:
|
|
36
|
+
# Already have a token
|
|
37
|
+
return self.config.token, self.config.refresh_token or ""
|
|
38
|
+
|
|
39
|
+
if self.config.refresh_token:
|
|
40
|
+
# Use refresh token to get new token
|
|
41
|
+
token = self.refresh_token(self.config.refresh_token)
|
|
42
|
+
self.config.token = token
|
|
43
|
+
return token, self.config.refresh_token
|
|
44
|
+
|
|
45
|
+
# Use username and password
|
|
46
|
+
url = self.config.get_endpoint_url("auth")
|
|
47
|
+
payload = {
|
|
48
|
+
"username": self.config.username,
|
|
49
|
+
"password": self.config.password,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
response = self.session.post(
|
|
54
|
+
url,
|
|
55
|
+
json=payload,
|
|
56
|
+
headers={"Content-Type": "application/json"},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if response.status_code != 200:
|
|
60
|
+
raise AuthenticationError(f"Authentication failed: {response.text}")
|
|
61
|
+
|
|
62
|
+
data = response.json()
|
|
63
|
+
token = data.get("token")
|
|
64
|
+
refresh_token = data.get("refreshToken")
|
|
65
|
+
|
|
66
|
+
if not token:
|
|
67
|
+
raise AuthenticationError("No token in response")
|
|
68
|
+
|
|
69
|
+
self.config.token = token
|
|
70
|
+
self.config.refresh_token = refresh_token
|
|
71
|
+
self._authenticated = True
|
|
72
|
+
|
|
73
|
+
return token, refresh_token
|
|
74
|
+
|
|
75
|
+
except requests.RequestException as e:
|
|
76
|
+
raise ConnectionError(f"Connection error: {e}")
|
|
77
|
+
|
|
78
|
+
def refresh_token(self, refresh_token: str) -> str:
|
|
79
|
+
"""Get a new token using refresh token.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
refresh_token: Refresh token
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
New authentication token
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
AuthenticationError: If token refresh fails
|
|
89
|
+
ConnectionError: If connection fails
|
|
90
|
+
"""
|
|
91
|
+
url = self.config.get_endpoint_url("refresh")
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
response = self.session.get(
|
|
95
|
+
url,
|
|
96
|
+
cookies={"refresh_token": refresh_token},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if response.status_code != 200:
|
|
100
|
+
raise AuthenticationError(f"Token refresh failed: {response.text}")
|
|
101
|
+
|
|
102
|
+
data = response.json()
|
|
103
|
+
token = data.get("token")
|
|
104
|
+
|
|
105
|
+
if not token:
|
|
106
|
+
raise AuthenticationError("No token in refresh response")
|
|
107
|
+
|
|
108
|
+
return token
|
|
109
|
+
|
|
110
|
+
except requests.RequestException as e:
|
|
111
|
+
raise ConnectionError(f"Connection error: {e}")
|
|
112
|
+
|
|
113
|
+
def logout(self) -> bool:
|
|
114
|
+
"""Logout and invalidate refresh token.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
True if successful
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
ConnectionError: If connection fails
|
|
121
|
+
"""
|
|
122
|
+
if not self.config.refresh_token:
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
url = self.config.get_endpoint_url("logout")
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
response = self.session.get(
|
|
129
|
+
url,
|
|
130
|
+
cookies={"refresh_token": self.config.refresh_token},
|
|
131
|
+
)
|
|
132
|
+
return response.status_code == 200
|
|
133
|
+
|
|
134
|
+
except requests.RequestException as e:
|
|
135
|
+
raise ConnectionError(f"Connection error: {e}")
|
|
136
|
+
|
|
137
|
+
def query(
|
|
138
|
+
self,
|
|
139
|
+
query: str,
|
|
140
|
+
variables: Optional[Dict[str, Any]] = None,
|
|
141
|
+
) -> Dict[str, Any]:
|
|
142
|
+
"""Execute a GraphQL query.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
query: GraphQL query string
|
|
146
|
+
variables: Query variables
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
GraphQL response data
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
GraphQLError: If query returns errors
|
|
153
|
+
ConnectionError: If connection fails
|
|
154
|
+
"""
|
|
155
|
+
# Ensure we're authenticated
|
|
156
|
+
if not self.config.token:
|
|
157
|
+
self.authenticate()
|
|
158
|
+
|
|
159
|
+
url = self.config.get_endpoint_url("graphql")
|
|
160
|
+
|
|
161
|
+
payload = {
|
|
162
|
+
"query": query,
|
|
163
|
+
"variables": variables or {},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
headers = {
|
|
167
|
+
"Content-Type": "application/json",
|
|
168
|
+
"Authorization": f"Bearer {self.config.token}",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
data = json.dumps(payload)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
response = self.session.post(url, data=data, headers=headers)
|
|
175
|
+
|
|
176
|
+
# Check for HTTP errors
|
|
177
|
+
if response.status_code == 401:
|
|
178
|
+
raise AuthenticationError(
|
|
179
|
+
"Authentication failed. Token may be expired. Try logging in again."
|
|
180
|
+
)
|
|
181
|
+
elif response.status_code != 200:
|
|
182
|
+
raise ConnectionError(
|
|
183
|
+
f"HTTP {response.status_code}: {response.text}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
result = response.json()
|
|
187
|
+
|
|
188
|
+
# Check for GraphQL errors
|
|
189
|
+
if "errors" in result:
|
|
190
|
+
error_messages = [err.get("message", str(err)) for err in result["errors"]]
|
|
191
|
+
raise GraphQLError("; ".join(error_messages))
|
|
192
|
+
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
except requests.RequestException as e:
|
|
196
|
+
raise ConnectionError(f"Connection error: {e}")
|
|
197
|
+
|
|
198
|
+
def ensure_authenticated(self) -> None:
|
|
199
|
+
"""Ensure client is authenticated, authenticate if not."""
|
|
200
|
+
if not self.config.token:
|
|
201
|
+
self.authenticate()
|
|
202
|
+
|
|
203
|
+
def ensure_ldap_connection(self) -> bool:
|
|
204
|
+
from ldap3 import Server, Connection, ALL, MODIFY_REPLACE
|
|
205
|
+
if (self.config.ldap_server is not None) and (self.config.ldap_base_dn is not None):
|
|
206
|
+
conn = None
|
|
207
|
+
try:
|
|
208
|
+
server = Server(self.config.ldap_server, get_info=ALL)
|
|
209
|
+
conn = Connection(server, self.config.ldap_bind_dn, self.config.ldap_bind_password, auto_bind=True)
|
|
210
|
+
except Exception:
|
|
211
|
+
return False
|
|
212
|
+
return True
|
|
213
|
+
return False
|
lldap/config.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
|
|
2
|
+
from typing import Optional, Dict
|
|
3
|
+
from .exceptions import ConfigurationError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Config:
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
http_url: Optional[str] = "http://localhost:17170",
|
|
10
|
+
username: Optional[str] = "admin",
|
|
11
|
+
password: Optional[str] = None,
|
|
12
|
+
token: Optional[str] = None,
|
|
13
|
+
refresh_token: Optional[str] = None,
|
|
14
|
+
user_dn: Optional[str] = None,
|
|
15
|
+
ldap_server: Optional[str] = None,
|
|
16
|
+
endpoints: Optional[Dict[str, str]] = None,
|
|
17
|
+
):
|
|
18
|
+
|
|
19
|
+
self.http_url = http_url
|
|
20
|
+
self.username = username
|
|
21
|
+
self.password = password
|
|
22
|
+
self.token = token
|
|
23
|
+
self.refresh_token = refresh_token
|
|
24
|
+
self.user_dn = user_dn
|
|
25
|
+
self.ldap_server = ldap_server
|
|
26
|
+
self.endpoints = endpoints or {
|
|
27
|
+
"auth": "/auth/simple/login",
|
|
28
|
+
"graphql": "/api/graphql",
|
|
29
|
+
"logout": "/auth/logout",
|
|
30
|
+
"refresh": "/auth/refresh",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def validate(self) -> None:
|
|
34
|
+
"""Validate that required configuration is present."""
|
|
35
|
+
has_token = bool(self.token)
|
|
36
|
+
has_credentials = bool(self.username and self.password)
|
|
37
|
+
has_refresh = bool(self.refresh_token)
|
|
38
|
+
|
|
39
|
+
if not (has_token or has_credentials or has_refresh):
|
|
40
|
+
raise ConfigurationError(
|
|
41
|
+
"Missing authentication: provide either token, refresh_token, or username+password"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def get_endpoint_url(self, endpoint: str) -> str:
|
|
45
|
+
"""Get full URL for an endpoint."""
|
|
46
|
+
return f"{self.http_url}{self.endpoints[endpoint]}"
|
lldap/exceptions.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Exception classes for LLDAP."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LLDAPError(Exception):
|
|
5
|
+
"""Base exception for all LLDAP errors."""
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthenticationError(LLDAPError):
|
|
10
|
+
"""Raised when authentication fails."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConnectionError(LLDAPError):
|
|
15
|
+
"""Raised when connection to LLDAP server fails."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GraphQLError(LLDAPError):
|
|
20
|
+
"""Raised when GraphQL query returns an error."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigurationError(LLDAPError):
|
|
25
|
+
"""Raised when configuration is invalid or missing."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ValidationError(LLDAPError):
|
|
30
|
+
"""Raised when input validation fails."""
|
|
31
|
+
pass
|
lldap/groups.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Group management operations."""
|
|
2
|
+
|
|
3
|
+
from typing import List, Dict, Any, Optional, Union
|
|
4
|
+
from .client import LLDAPClient
|
|
5
|
+
from .exceptions import ValidationError
|
|
6
|
+
from .models import Group, User
|
|
7
|
+
|
|
8
|
+
# BASE_GROUP_ATTRIBUTES = ["groupid", "creationDate", "uuid", "displayName"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GroupManager:
|
|
13
|
+
"""Manages group operations."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, client: LLDAPClient):
|
|
16
|
+
"""Initialize group manager.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
client: LLDAP client instance
|
|
20
|
+
"""
|
|
21
|
+
self.client = client
|
|
22
|
+
|
|
23
|
+
def list_groups(self) -> List[Group]:
|
|
24
|
+
"""Get list of all groups.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
List of group objects
|
|
28
|
+
"""
|
|
29
|
+
query = "{groups{id creationDate uuid displayName}}"
|
|
30
|
+
result = self.client.query(query)
|
|
31
|
+
groups = []
|
|
32
|
+
for grp in result.get("data", {}).get("groups", []):
|
|
33
|
+
groups.append(Group(
|
|
34
|
+
groupid=grp["id"],
|
|
35
|
+
creation_date=grp["creationDate"],
|
|
36
|
+
uuid=grp["uuid"],
|
|
37
|
+
display_name=grp["displayName"],
|
|
38
|
+
))
|
|
39
|
+
return groups
|
|
40
|
+
|
|
41
|
+
def create_group(self, name: str) -> Dict[str, Any]:
|
|
42
|
+
"""Create a new group.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
name: Group display name
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Created group data
|
|
49
|
+
"""
|
|
50
|
+
query = """
|
|
51
|
+
mutation createGroup($group:String!){
|
|
52
|
+
createGroup(name:$group){id}
|
|
53
|
+
}
|
|
54
|
+
"""
|
|
55
|
+
variables = {"group": name}
|
|
56
|
+
result = self.client.query(query, variables)
|
|
57
|
+
# return created group data
|
|
58
|
+
return result.get("data", {}).get("createGroup", {})
|
|
59
|
+
|
|
60
|
+
def fetch_group_by_id(self, group_id: int) -> Optional[Group]:
|
|
61
|
+
for group in self.list_groups():
|
|
62
|
+
if group.groupid == group_id:
|
|
63
|
+
return group
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get_group_id(self, name: str) -> Optional[int]:
|
|
68
|
+
"""Get group ID by display name.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
name: Group display name
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Group ID or None if not found
|
|
75
|
+
"""
|
|
76
|
+
groups = self.list_groups()
|
|
77
|
+
for group in groups:
|
|
78
|
+
if group.display_name == name:
|
|
79
|
+
return group.groupid
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def _resolve_group_id(self, group_identifier: Union[str, int]) -> int:
|
|
83
|
+
"""Resolve group name or ID to group ID.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
group_identifier: Either group name (str) or group ID (int)
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Group ID
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValidationError: If group not found or invalid type
|
|
93
|
+
"""
|
|
94
|
+
if isinstance(group_identifier, int):
|
|
95
|
+
return group_identifier
|
|
96
|
+
elif isinstance(group_identifier, str):
|
|
97
|
+
group_id = self.get_group_id(group_identifier)
|
|
98
|
+
if group_id is None:
|
|
99
|
+
raise ValidationError(f"Group not found: {group_identifier}")
|
|
100
|
+
return group_id
|
|
101
|
+
else:
|
|
102
|
+
raise ValidationError(f"Group identifier must be str (name) or int (id), got {type(group_identifier).__name__}")
|
|
103
|
+
|
|
104
|
+
def delete_group(self, group_identifier: Union[str, int]) -> bool:
|
|
105
|
+
"""Delete a group by name or ID.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
group_identifier: Group name (str) or group ID (int)
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
True if successful
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
ValidationError: If group not found
|
|
115
|
+
"""
|
|
116
|
+
group_id = self._resolve_group_id(group_identifier)
|
|
117
|
+
|
|
118
|
+
query = """
|
|
119
|
+
mutation deleteGroup($id:Int!){
|
|
120
|
+
deleteGroup(groupId:$id){ok}
|
|
121
|
+
}
|
|
122
|
+
"""
|
|
123
|
+
variables = {"id": group_id}
|
|
124
|
+
result = self.client.query(query, variables)
|
|
125
|
+
return result.get("data", {}).get("deleteGroup", {}).get("ok", False)
|
|
126
|
+
|
|
127
|
+
def list_group_users(self, group_identifier: Union[str, int]) -> List[User]:
|
|
128
|
+
"""List users in a group by name or ID.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
group_identifier: Group name (str) or group ID (int)
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
List of User objects
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ValidationError: If group not found
|
|
138
|
+
"""
|
|
139
|
+
group_id = self._resolve_group_id(group_identifier)
|
|
140
|
+
|
|
141
|
+
query = """
|
|
142
|
+
query listUsersByGroupName($id:Int!){
|
|
143
|
+
group:group(groupId:$id){users{id email displayName firstName lastName avatar}}
|
|
144
|
+
}
|
|
145
|
+
"""
|
|
146
|
+
variables = {"id": group_id}
|
|
147
|
+
result = self.client.query(query, variables)
|
|
148
|
+
users_data = result.get("data", {}).get("group", {}).get("users", [])
|
|
149
|
+
|
|
150
|
+
return [User(
|
|
151
|
+
user_id=user["id"],
|
|
152
|
+
email=user["email"],
|
|
153
|
+
display_name=user["displayName"],
|
|
154
|
+
first_name=user["firstName"],
|
|
155
|
+
last_name=user["lastName"],
|
|
156
|
+
avatar=user.get("avatar")
|
|
157
|
+
) for user in users_data]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
|
lldap/models.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
class User:
|
|
5
|
+
"""Represents a user entity."""
|
|
6
|
+
def __init__(self, user_id: str, email: str, display_name: str, first_name: str, last_name: str, avatar: Optional[str]):
|
|
7
|
+
self.user_id = user_id
|
|
8
|
+
self.email = email
|
|
9
|
+
self.display_name = display_name
|
|
10
|
+
self.first_name = first_name
|
|
11
|
+
self.last_name = last_name
|
|
12
|
+
self.avatar = avatar
|
|
13
|
+
def __repr__(self):
|
|
14
|
+
return f"<User id={self.user_id} email={self.email} name={self.display_name}>"
|
|
15
|
+
|
|
16
|
+
class Group:
|
|
17
|
+
"""Represents a group entity."""
|
|
18
|
+
def __init__(self, groupid: int, creation_date: str, uuid: str, display_name: str):
|
|
19
|
+
self.groupid = groupid
|
|
20
|
+
self.creation_date = creation_date
|
|
21
|
+
self.uuid = uuid
|
|
22
|
+
self.display_name = display_name
|
|
23
|
+
self.creation_date = creation_date
|
|
24
|
+
def __repr__(self):
|
|
25
|
+
return f"<Group id={self.groupid} name={self.display_name} uuid={self.uuid}>"
|
|
26
|
+
|
|
27
|
+
|
lldap/users.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""User management operations."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import List, Dict, Any, Optional
|
|
5
|
+
from .client import LLDAPClient
|
|
6
|
+
from .exceptions import ValidationError
|
|
7
|
+
from .models import User, Group
|
|
8
|
+
|
|
9
|
+
class UserManager:
|
|
10
|
+
"""Manages user operations."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, client: LLDAPClient):
|
|
13
|
+
"""Initialize user manager.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
client: LLDAP client instance
|
|
17
|
+
"""
|
|
18
|
+
self.client = client
|
|
19
|
+
|
|
20
|
+
def list_users(self) -> List[User]:
|
|
21
|
+
"""Get list of all users.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
List of user objects
|
|
25
|
+
"""
|
|
26
|
+
query = "{users{id creationDate uuid email displayName firstName lastName, avatar}}"
|
|
27
|
+
result = self.client.query(query)
|
|
28
|
+
users = []
|
|
29
|
+
for usr in result.get("data", {}).get("users", []):
|
|
30
|
+
users.append(User(
|
|
31
|
+
user_id=usr["id"],
|
|
32
|
+
email=usr["email"],
|
|
33
|
+
display_name=usr["displayName"],
|
|
34
|
+
first_name=usr["firstName"],
|
|
35
|
+
last_name=usr["lastName"],
|
|
36
|
+
avatar=usr.get("avatar"),
|
|
37
|
+
))
|
|
38
|
+
return users
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_user_id_by_email(self, email: str) -> Optional[str]:
|
|
42
|
+
"""Get user ID from email address.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
email: Email address
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
User ID or None if not found
|
|
49
|
+
"""
|
|
50
|
+
query = "{users{id email}}"
|
|
51
|
+
result = self.client.query(query)
|
|
52
|
+
users = self.list_users()
|
|
53
|
+
|
|
54
|
+
for user in users:
|
|
55
|
+
if user.email == email:
|
|
56
|
+
return user.user_id
|
|
57
|
+
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def create_user(
|
|
63
|
+
self,
|
|
64
|
+
user_id: str,
|
|
65
|
+
email: str,
|
|
66
|
+
display_name: Optional[str] = None,
|
|
67
|
+
first_name: Optional[str] = None,
|
|
68
|
+
last_name: Optional[str] = None,
|
|
69
|
+
) -> Dict[str, Any]:
|
|
70
|
+
"""Create a new user.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
user_id: User ID
|
|
74
|
+
email: Email address
|
|
75
|
+
display_name: Display name
|
|
76
|
+
first_name: First name
|
|
77
|
+
last_name: Last name
|
|
78
|
+
avatar_path: Path to avatar image file (JPEG)
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Created user data
|
|
82
|
+
"""
|
|
83
|
+
query = """
|
|
84
|
+
mutation createUser($user:CreateUserInput!){
|
|
85
|
+
createUser(user:$user){id email displayName firstName lastName avatar}
|
|
86
|
+
}
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
variables = {
|
|
90
|
+
"user": {
|
|
91
|
+
"id": user_id,
|
|
92
|
+
"email": email,
|
|
93
|
+
"displayName": display_name or "",
|
|
94
|
+
"firstName": first_name or "",
|
|
95
|
+
"lastName": last_name or "",
|
|
96
|
+
"avatar": None,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
result = self.client.query(query, variables)
|
|
102
|
+
return result.get("data", {}).get("createUser", {})
|
|
103
|
+
|
|
104
|
+
def delete_user(self, user_id: str) -> bool:
|
|
105
|
+
"""Delete a user.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
user_id: User ID
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
True if successful
|
|
112
|
+
"""
|
|
113
|
+
query = """
|
|
114
|
+
mutation deleteUser($userId:String!){
|
|
115
|
+
deleteUser(userId:$userId){ok}
|
|
116
|
+
}
|
|
117
|
+
"""
|
|
118
|
+
variables = {"userId": user_id}
|
|
119
|
+
result = self.client.query(query, variables)
|
|
120
|
+
return result.get("data", {}).get("deleteUser", {}).get("ok", False)
|
|
121
|
+
|
|
122
|
+
def list_user_attributes(self, user_id: str) -> List[str]:
|
|
123
|
+
"""List attributes for a user.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
user_id: User ID
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
List of attribute names
|
|
130
|
+
"""
|
|
131
|
+
query = """
|
|
132
|
+
query getUserInfo($id:String!){
|
|
133
|
+
user(userId:$id){attributes{name}}
|
|
134
|
+
}
|
|
135
|
+
"""
|
|
136
|
+
variables = {"id": user_id}
|
|
137
|
+
result = self.client.query(query, variables)
|
|
138
|
+
attributes = result.get("data", {}).get("user", {}).get("attributes", [])
|
|
139
|
+
names = [attr["name"] for attr in attributes]
|
|
140
|
+
return sorted(names)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def list_user_groups(self, user_id: str) -> List[str]:
|
|
145
|
+
"""List groups that a user belongs to.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
user_id: User ID
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
List of group display names
|
|
152
|
+
"""
|
|
153
|
+
query = """
|
|
154
|
+
query listGroupsByUserId($id:String!){
|
|
155
|
+
user(userId:$id){groups{displayName}}
|
|
156
|
+
}
|
|
157
|
+
"""
|
|
158
|
+
variables = {"id": user_id}
|
|
159
|
+
result = self.client.query(query, variables)
|
|
160
|
+
groups = result.get("data", {}).get("user", {}).get("groups", [])
|
|
161
|
+
return [group["displayName"] for group in groups]
|
|
162
|
+
|
|
163
|
+
def add_user_to_group(self, user_id: str, group_id: int) -> bool:
|
|
164
|
+
"""Add user to a group.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
user_id: User ID
|
|
168
|
+
group_id: Group ID (integer)
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
True if successful
|
|
172
|
+
"""
|
|
173
|
+
query = """
|
|
174
|
+
mutation addUserToGroup($userId:String!,$groupId:Int!){
|
|
175
|
+
addUserToGroup(userId:$userId,groupId:$groupId){ok}
|
|
176
|
+
}
|
|
177
|
+
"""
|
|
178
|
+
variables = {"userId": user_id, "groupId": group_id}
|
|
179
|
+
result = self.client.query(query, variables)
|
|
180
|
+
return result.get("data", {}).get("addUserToGroup", {}).get("ok", False)
|
|
181
|
+
|
|
182
|
+
def remove_user_from_group(self, user_id: str, group_id: int) -> bool:
|
|
183
|
+
"""Remove user from a group.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
user_id: User ID
|
|
187
|
+
group_id: Group ID (integer)
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
True if successful
|
|
191
|
+
"""
|
|
192
|
+
query = """
|
|
193
|
+
mutation removeUserFromGroup($userId:String!,$groupId:Int!){
|
|
194
|
+
removeUserFromGroup(userId:$userId,groupId:$groupId){ok}
|
|
195
|
+
}
|
|
196
|
+
"""
|
|
197
|
+
variables = {"userId": user_id, "groupId": group_id}
|
|
198
|
+
result = self.client.query(query, variables)
|
|
199
|
+
return result.get("data", {}).get("removeUserFromGroup", {}).get("ok", False)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lldap-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python tool for managing LLDAP servers
|
|
5
|
+
Home-page: https://github.com/luca2618/lldap-py
|
|
6
|
+
Author: Lucas Sylvester
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: System Administrators
|
|
9
|
+
Classifier: Topic :: System :: Systems Administration :: Authentication/Directory :: LDAP
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: requests>=2.28.0
|
|
16
|
+
Requires-Dist: toml>=0.10.2
|
|
17
|
+
Requires-Dist: click>=8.0.0
|
|
18
|
+
Requires-Dist: ldap3>=2.9.0
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest; extra == "test"
|
|
21
|
+
Dynamic: author
|
|
22
|
+
Dynamic: classifier
|
|
23
|
+
Dynamic: description
|
|
24
|
+
Dynamic: description-content-type
|
|
25
|
+
Dynamic: home-page
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
Dynamic: provides-extra
|
|
28
|
+
Dynamic: requires-dist
|
|
29
|
+
Dynamic: requires-python
|
|
30
|
+
Dynamic: summary
|
|
31
|
+
|
|
32
|
+
# lldap-py
|
|
33
|
+
|
|
34
|
+
Python client library for managing LLDAP servers [lldap/lldap](https://github.com/lldap/lldap)
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
This package provides a Python interface to interact with LLDAP servers for user and group management. The idea is that it would be used in an onboarding/offboarding automation script and make similar automation tasks easier.
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
- Python 3.8+
|
|
42
|
+
- requests
|
|
43
|
+
- ldap3
|
|
44
|
+
- toml
|
|
45
|
+
- click
|
|
46
|
+
|
|
47
|
+
## TODO
|
|
48
|
+
- Maybe improve error handling and passing of graphql errors to the user.
|
|
49
|
+
- Add more examples and documentation.
|
|
50
|
+
- Check coverage of tests
|
|
51
|
+
- Add support for direct LDAP operations alongside GraphQL(Mainly for password management, to be used for initial random password generation).
|
|
52
|
+
- Test against https (and ldaps connections for above point).
|
|
53
|
+
- Add support for costum user and group attributes.
|
|
54
|
+
|
|
55
|
+
## Credit
|
|
56
|
+
This project is heavely inspired by and uses alot of code from [Zepmann/lldap-cli](https://github.com/Zepmann/lldap-cli) and [JaidenW/LLDAP-Discord](https://github.com/JaidenW/LLDAP-Discord)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
## License
|
|
60
|
+
|
|
61
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
lldap/__init__.py,sha256=I2OsG5GibcAa471_9iiGe7LYqOiaA6vqS7kw6BOjAb4,2261
|
|
2
|
+
lldap/client.py,sha256=_TkxfiPeT7meBxUilPZloTqtZ5_32BbGa1pp9XuM9QM,6978
|
|
3
|
+
lldap/config.py,sha256=r7SNn7KjTrlUIfKyhYKaG8QHiUwnanzy0Qn8Wm6TmuU,1605
|
|
4
|
+
lldap/exceptions.py,sha256=EwqhuchbNKKHb0qNtHk0pOlt4BouV0FbrmHHpzVTov8,646
|
|
5
|
+
lldap/groups.py,sha256=gMpOkRulAID-sMzhdY2_3amTXnoo4rsLV1PsTWHZvRg,5066
|
|
6
|
+
lldap/models.py,sha256=DuWXGveqQG1Ae1QU77hrCwTr_pxyUiKCcmUFHuGJMO4,1059
|
|
7
|
+
lldap/users.py,sha256=iPnBtBHC8ompgnlH2HY6J6ci5bduXFdrB_ULbfxc-no,6108
|
|
8
|
+
lldap_py-0.1.0.dist-info/licenses/LICENSE,sha256=0r3XKhURGT6PVzcrhPXg3NtusjrTWEOjR9UH-_QKBe0,1099
|
|
9
|
+
lldap_py-0.1.0.dist-info/METADATA,sha256=keilo5G3P4CTbb83nc7V_LZ5_TTyEvNHQOPdsJne4YE,2039
|
|
10
|
+
lldap_py-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
11
|
+
lldap_py-0.1.0.dist-info/top_level.txt,sha256=uxlPt9-Bi-TwywkVi52Y6LfC2zFwJm1ZwLa8zfSi-6Y,6
|
|
12
|
+
lldap_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LLDAP-py Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lldap
|