Skip to content

Commit bd61286

Browse files
Feat: Add configurable LDAP group member attribute and single-run mode (#29)
1 parent 470d365 commit bd61286

File tree

4 files changed

+91
-21
lines changed

4 files changed

+91
-21
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ For example, a simple user base DN might look something like `OU=users,DC=exampl
3737
- `LDAP_GROUP_BASE_DN`: Base DN for groups
3838
- `LDAP_GROUP_FILTER`: LDAP filter to select groups
3939
- `LDAP_GROUP_NAME_ATTR`: Attribute used to extract group names (default: 'cn')
40+
- `LDAP_GROUP_MEMBER_ATTR`: Attribute used to extract group members (default: 'member')
4041
- `LDAP_HOST`: LDAP host
4142
- `LDAP_PORT`: LDAP port (default: '389')
4243
- `LDAP_USER_BASE_DN`: Base DN for users
@@ -48,6 +49,7 @@ For example, a simple user base DN might look something like `OU=users,DC=exampl
4849
- `POSTGRESQL_PORT`: PostgreSQL server port (default: '5432')
4950
- `POSTGRESQL_USERNAME`: Username of PostgreSQL user
5051
- `REPEAT_INTERVAL`: How often (in seconds) to wait before attempting to synchronise again (default: '300')
52+
- `SINGLE_RUN_MODE`: Enables single-run mode. If 'true', the synchronizer performs one sync and exits (e.g., for cronjobs). Otherwise, it runs continuously at `REPEAT_INTERVAL`. (default: 'false')
5153

5254
## Contributing
5355

guacamole_user_sync/ldap/ldap_client.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,13 @@ def __init__(
3030
auto_bind: bool = True,
3131
bind_dn: str | None = None,
3232
bind_password: str | None = None,
33+
group_member_attribute: str = "member",
3334
) -> None:
3435
self.auto_bind = auto_bind
3536
self.bind_dn = bind_dn
3637
self.bind_password = bind_password
3738
self.server = Server(hostname, get_info=ALL)
39+
self.group_member_attribute = group_member_attribute
3840

3941
@staticmethod
4042
def as_list(ldap_entry: str | list[str] | None) -> list[str]:
@@ -72,10 +74,22 @@ def connect(self) -> Connection:
7274
def search_groups(self, query: LDAPQuery) -> list[LDAPGroup]:
7375
output = []
7476
for entry in self.search(query):
77+
78+
member_of_attr = getattr(entry, 'memberOf', None)
79+
member_of_values = self.as_list(member_of_attr.value if member_of_attr else None)
80+
81+
member_attr = getattr(entry, self.group_member_attribute, None)
82+
member_values = self.as_list(member_attr.value if member_attr else None)
83+
84+
if hasattr(entry, self.group_member_attribute):
85+
member_values = self.as_list(getattr(entry, self.group_member_attribute).value)
86+
else:
87+
member_values = []
88+
7589
output.append(
7690
LDAPGroup(
77-
member_of=self.as_list(entry.memberOf.value),
78-
member_uid=self.as_list(entry.memberUid.value),
91+
member_of=member_of_values,
92+
member_uid=member_values,
7993
name=getattr(entry, query.id_attr).value,
8094
),
8195
)
@@ -86,12 +100,23 @@ def search_groups(self, query: LDAPQuery) -> list[LDAPGroup]:
86100
def search_users(self, query: LDAPQuery) -> list[LDAPUser]:
87101
output = []
88102
for entry in self.search(query):
103+
104+
member_of_attr = getattr(entry, 'memberOf', None)
105+
member_of_values = self.as_list(member_of_attr.value) if member_of_attr else []
106+
107+
display_name_attr = getattr(entry, 'displayName', None)
108+
display_name_value = display_name_attr.value if display_name_attr else ""
109+
110+
uid_attr = getattr(entry, 'uid', None)
111+
uid_value = uid_attr.value if uid_attr else ""
112+
113+
89114
output.append(
90115
LDAPUser(
91-
display_name=entry.displayName.value,
92-
member_of=self.as_list(entry.memberOf.value),
116+
display_name=display_name_value,
117+
member_of=member_of_values,
93118
name=getattr(entry, query.id_attr).value,
94-
uid=entry.uid.value,
119+
uid=uid_value,
95120
),
96121
)
97122
logger.debug("Found LDAP user %s", output[-1])

guacamole_user_sync/postgresql/postgresql_client.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import secrets
3+
import re
34
from datetime import UTC, datetime
45

56
from sqlalchemy.exc import SQLAlchemyError
@@ -35,6 +36,7 @@ def __init__(
3536
port: int,
3637
user_name: str,
3738
user_password: str,
39+
ldap_group_member_attr: str,
3840
) -> None:
3941
self.backend = PostgreSQLBackend(
4042
connection_details=PostgreSQLConnectionDetails(
@@ -45,7 +47,13 @@ def __init__(
4547
user_password=user_password,
4648
),
4749
)
50+
self.ldap_group_member_attr = ldap_group_member_attr
4851

52+
@staticmethod
53+
def extract_uid_from_dn(dn: str) -> str | None:
54+
match = re.search(r"uid=([^,]+)", dn)
55+
return match.group(1) if match else None
56+
4957
def assign_users_to_groups(
5058
self,
5159
groups: list[LDAPGroup],
@@ -94,11 +102,23 @@ def assign_users_to_groups(
94102
group.name,
95103
len(group.member_uid),
96104
)
97-
for user_uid in group.member_uid:
105+
for member_value in group.member_uid:
106+
uid_to_match = member_value
107+
108+
if self.ldap_group_member_attr == "member" or "=" in member_value:
109+
extracted_uid = self.extract_uid_from_dn(member_value)
110+
if extracted_uid:
111+
uid_to_match = extracted_uid
112+
else:
113+
logger.debug(
114+
"Could not extract UID from member value (possibly not a DN or malformed): %s. Using raw value as UID.",
115+
member_value
116+
)
117+
98118
try:
99-
user = next(filter(lambda u: u.uid == user_uid, users))
119+
user = next(filter(lambda u: u.uid == uid_to_match, users))
100120
except StopIteration:
101-
logger.debug("Could not find LDAP user with UID %s", user_uid)
121+
logger.debug("Could not find LDAP user with UID %s (from member value: %s)", uid_to_match, member_value)
102122
continue
103123
try:
104124
user_entity_id = next(
@@ -117,7 +137,7 @@ def assign_users_to_groups(
117137
except StopIteration:
118138
logger.debug(
119139
"Could not find entity ID for LDAP user '%s'",
120-
user_uid,
140+
user.name,
121141
)
122142
continue
123143
# Record user/group associations

synchronise.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#! /usr/bin/env python3
22
import logging
33
import os
4+
import sys
45
import time
56

67
from guacamole_user_sync.ldap import LDAPClient
@@ -25,12 +26,15 @@ def main( # noqa: PLR0913
2526
postgresql_port: int,
2627
postgresql_user_name: str,
2728
repeat_interval: int,
28-
) -> None:
29+
ldap_group_member_attr: str,
30+
single_run_mode: bool = False,
31+
) -> int:
2932
# Initialise LDAP resources
3033
ldap_client = LDAPClient(
3134
f"{ldap_host}:{ldap_port}",
3235
bind_dn=ldap_bind_dn,
3336
bind_password=ldap_bind_password,
37+
group_member_attribute=ldap_group_member_attr,
3438
)
3539
ldap_group_query = LDAPQuery(
3640
base_dn=ldap_group_base_dn,
@@ -48,21 +52,34 @@ def main( # noqa: PLR0913
4852
port=postgresql_port,
4953
user_name=postgresql_user_name,
5054
user_password=postgresql_password,
55+
ldap_group_member_attr=ldap_group_member_attr,
5156
)
5257

53-
# Loop until terminated
54-
while True:
55-
# Run synchronisation step
56-
synchronise(
58+
if single_run_mode:
59+
success = synchronise(
5760
ldap_client=ldap_client,
5861
ldap_group_query=ldap_group_query,
5962
ldap_user_query=ldap_user_query,
6063
postgresql_client=postgresql_client,
64+
ldap_group_member_attr=ldap_group_member_attr,
6165
)
66+
return 0 if success else 1
67+
else:
68+
# Loop until terminated
69+
while True:
70+
# Run synchronisation step
71+
synchronise(
72+
ldap_client=ldap_client,
73+
ldap_group_query=ldap_group_query,
74+
ldap_user_query=ldap_user_query,
75+
postgresql_client=postgresql_client,
76+
ldap_group_member_attr=ldap_group_member_attr,
77+
)
6278

63-
# Wait before repeating
64-
logger.info("Waiting %s seconds.", repeat_interval)
65-
time.sleep(repeat_interval)
79+
# Wait before repeating
80+
logger.info("Waiting %s seconds.", repeat_interval)
81+
time.sleep(repeat_interval)
82+
return 0
6683

6784

6885
def synchronise(
@@ -71,21 +88,24 @@ def synchronise(
7188
ldap_group_query: LDAPQuery,
7289
ldap_user_query: LDAPQuery,
7390
postgresql_client: PostgreSQLClient,
74-
) -> None:
91+
ldap_group_member_attr: str
92+
) -> bool:
7593
logger.info("Starting synchronisation.")
7694
try:
7795
ldap_groups = ldap_client.search_groups(ldap_group_query)
7896
ldap_users = ldap_client.search_users(ldap_user_query)
7997
except LDAPError:
8098
logger.warning("LDAP server query failed")
81-
return
99+
return False
82100

83101
try:
84102
postgresql_client.ensure_schema(SchemaVersion.v1_5_5)
85103
postgresql_client.update(groups=ldap_groups, users=ldap_users)
86104
except PostgreSQLError:
87105
logger.warning("PostgreSQL update failed")
88-
return
106+
return False
107+
108+
return True
89109

90110

91111
if __name__ == "__main__":
@@ -127,12 +147,13 @@ def synchronise(
127147
)
128148
logger = logging.getLogger("guacamole_user_sync")
129149

130-
main(
150+
exit_code = main(
131151
ldap_bind_dn=os.getenv("LDAP_BIND_DN", None),
132152
ldap_bind_password=os.getenv("LDAP_BIND_PASSWORD", None),
133153
ldap_group_base_dn=ldap_group_base_dn,
134154
ldap_group_filter=ldap_group_filter,
135155
ldap_group_name_attr=os.getenv("LDAP_GROUP_NAME_ATTR", "cn"),
156+
ldap_group_member_attr=os.getenv("LDAP_GROUP_MEMBER_ATTR", "memberUid"),
136157
ldap_host=ldap_host,
137158
ldap_port=int(os.getenv("LDAP_PORT", "389")),
138159
ldap_user_base_dn=ldap_user_base_dn,
@@ -144,4 +165,6 @@ def synchronise(
144165
postgresql_port=int(os.getenv("POSTGRESQL_PORT", "5432")),
145166
postgresql_user_name=postgresql_user_name,
146167
repeat_interval=int(os.getenv("REPEAT_INTERVAL", "300")),
168+
single_run_mode=os.getenv("SINGLE_RUN_MODE", "false").lower() == "true",
147169
)
170+
sys.exit(exit_code)

0 commit comments

Comments
 (0)