Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ For example, a simple user base DN might look something like `OU=users,DC=exampl
- `LDAP_GROUP_BASE_DN`: Base DN for groups
- `LDAP_GROUP_FILTER`: LDAP filter to select groups
- `LDAP_GROUP_NAME_ATTR`: Attribute used to extract group names (default: 'cn')
- `LDAP_GROUP_MEMBER_ATTR`: Attribute used to extract group members (default: 'member')
- `LDAP_HOST`: LDAP host
- `LDAP_PORT`: LDAP port (default: '389')
- `LDAP_USER_BASE_DN`: Base DN for users
Expand All @@ -48,6 +49,7 @@ For example, a simple user base DN might look something like `OU=users,DC=exampl
- `POSTGRESQL_PORT`: PostgreSQL server port (default: '5432')
- `POSTGRESQL_USERNAME`: Username of PostgreSQL user
- `REPEAT_INTERVAL`: How often (in seconds) to wait before attempting to synchronise again (default: '300')
- `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')

## Contributing

Expand Down
35 changes: 30 additions & 5 deletions guacamole_user_sync/ldap/ldap_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ def __init__(
auto_bind: bool = True,
bind_dn: str | None = None,
bind_password: str | None = None,
group_member_attribute: str = "member",
) -> None:
self.auto_bind = auto_bind
self.bind_dn = bind_dn
self.bind_password = bind_password
self.server = Server(hostname, get_info=ALL)
self.group_member_attribute = group_member_attribute

@staticmethod
def as_list(ldap_entry: str | list[str] | None) -> list[str]:
Expand Down Expand Up @@ -72,10 +74,22 @@ def connect(self) -> Connection:
def search_groups(self, query: LDAPQuery) -> list[LDAPGroup]:
output = []
for entry in self.search(query):

member_of_attr = getattr(entry, 'memberOf', None)
member_of_values = self.as_list(member_of_attr.value if member_of_attr else None)

member_attr = getattr(entry, self.group_member_attribute, None)
member_values = self.as_list(member_attr.value if member_attr else None)

if hasattr(entry, self.group_member_attribute):
member_values = self.as_list(getattr(entry, self.group_member_attribute).value)
else:
member_values = []

output.append(
LDAPGroup(
member_of=self.as_list(entry.memberOf.value),
member_uid=self.as_list(entry.memberUid.value),
member_of=member_of_values,
member_uid=member_values,
name=getattr(entry, query.id_attr).value,
),
)
Expand All @@ -86,12 +100,23 @@ def search_groups(self, query: LDAPQuery) -> list[LDAPGroup]:
def search_users(self, query: LDAPQuery) -> list[LDAPUser]:
output = []
for entry in self.search(query):

member_of_attr = getattr(entry, 'memberOf', None)
member_of_values = self.as_list(member_of_attr.value) if member_of_attr else []

display_name_attr = getattr(entry, 'displayName', None)
display_name_value = display_name_attr.value if display_name_attr else ""

uid_attr = getattr(entry, 'uid', None)
uid_value = uid_attr.value if uid_attr else ""


output.append(
LDAPUser(
display_name=entry.displayName.value,
member_of=self.as_list(entry.memberOf.value),
display_name=display_name_value,
member_of=member_of_values,
name=getattr(entry, query.id_attr).value,
uid=entry.uid.value,
uid=uid_value,
),
)
logger.debug("Found LDAP user %s", output[-1])
Expand Down
28 changes: 24 additions & 4 deletions guacamole_user_sync/postgresql/postgresql_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import secrets
import re
from datetime import UTC, datetime

from sqlalchemy.exc import SQLAlchemyError
Expand Down Expand Up @@ -35,6 +36,7 @@ def __init__(
port: int,
user_name: str,
user_password: str,
ldap_group_member_attr: str,
) -> None:
self.backend = PostgreSQLBackend(
connection_details=PostgreSQLConnectionDetails(
Expand All @@ -45,7 +47,13 @@ def __init__(
user_password=user_password,
),
)
self.ldap_group_member_attr = ldap_group_member_attr

@staticmethod
def extract_uid_from_dn(dn: str) -> str | None:
match = re.search(r"uid=([^,]+)", dn)
return match.group(1) if match else None

def assign_users_to_groups(
self,
groups: list[LDAPGroup],
Expand Down Expand Up @@ -94,11 +102,23 @@ def assign_users_to_groups(
group.name,
len(group.member_uid),
)
for user_uid in group.member_uid:
for member_value in group.member_uid:
uid_to_match = member_value

if self.ldap_group_member_attr == "member" or "=" in member_value:
extracted_uid = self.extract_uid_from_dn(member_value)
if extracted_uid:
uid_to_match = extracted_uid
else:
logger.debug(
"Could not extract UID from member value (possibly not a DN or malformed): %s. Using raw value as UID.",
member_value
)

try:
user = next(filter(lambda u: u.uid == user_uid, users))
user = next(filter(lambda u: u.uid == uid_to_match, users))
except StopIteration:
logger.debug("Could not find LDAP user with UID %s", user_uid)
logger.debug("Could not find LDAP user with UID %s (from member value: %s)", uid_to_match, member_value)
continue
try:
user_entity_id = next(
Expand All @@ -117,7 +137,7 @@ def assign_users_to_groups(
except StopIteration:
logger.debug(
"Could not find entity ID for LDAP user '%s'",
user_uid,
user.name,
)
continue
# Record user/group associations
Expand Down
47 changes: 35 additions & 12 deletions synchronise.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#! /usr/bin/env python3
import logging
import os
import sys
import time

from guacamole_user_sync.ldap import LDAPClient
Expand All @@ -25,12 +26,15 @@ def main( # noqa: PLR0913
postgresql_port: int,
postgresql_user_name: str,
repeat_interval: int,
) -> None:
ldap_group_member_attr: str,
single_run_mode: bool = False,
) -> int:
# Initialise LDAP resources
ldap_client = LDAPClient(
f"{ldap_host}:{ldap_port}",
bind_dn=ldap_bind_dn,
bind_password=ldap_bind_password,
group_member_attribute=ldap_group_member_attr,
)
ldap_group_query = LDAPQuery(
base_dn=ldap_group_base_dn,
Expand All @@ -48,21 +52,34 @@ def main( # noqa: PLR0913
port=postgresql_port,
user_name=postgresql_user_name,
user_password=postgresql_password,
ldap_group_member_attr=ldap_group_member_attr,
)

# Loop until terminated
while True:
# Run synchronisation step
synchronise(
if single_run_mode:
success = synchronise(
ldap_client=ldap_client,
ldap_group_query=ldap_group_query,
ldap_user_query=ldap_user_query,
postgresql_client=postgresql_client,
ldap_group_member_attr=ldap_group_member_attr,
)
return 0 if success else 1
else:
# Loop until terminated
while True:
# Run synchronisation step
synchronise(
ldap_client=ldap_client,
ldap_group_query=ldap_group_query,
ldap_user_query=ldap_user_query,
postgresql_client=postgresql_client,
ldap_group_member_attr=ldap_group_member_attr,
)

# Wait before repeating
logger.info("Waiting %s seconds.", repeat_interval)
time.sleep(repeat_interval)
# Wait before repeating
logger.info("Waiting %s seconds.", repeat_interval)
time.sleep(repeat_interval)
return 0


def synchronise(
Expand All @@ -71,21 +88,24 @@ def synchronise(
ldap_group_query: LDAPQuery,
ldap_user_query: LDAPQuery,
postgresql_client: PostgreSQLClient,
) -> None:
ldap_group_member_attr: str
) -> bool:
logger.info("Starting synchronisation.")
try:
ldap_groups = ldap_client.search_groups(ldap_group_query)
ldap_users = ldap_client.search_users(ldap_user_query)
except LDAPError:
logger.warning("LDAP server query failed")
return
return False

try:
postgresql_client.ensure_schema(SchemaVersion.v1_5_5)
postgresql_client.update(groups=ldap_groups, users=ldap_users)
except PostgreSQLError:
logger.warning("PostgreSQL update failed")
return
return False

return True


if __name__ == "__main__":
Expand Down Expand Up @@ -127,12 +147,13 @@ def synchronise(
)
logger = logging.getLogger("guacamole_user_sync")

main(
exit_code = main(
ldap_bind_dn=os.getenv("LDAP_BIND_DN", None),
ldap_bind_password=os.getenv("LDAP_BIND_PASSWORD", None),
ldap_group_base_dn=ldap_group_base_dn,
ldap_group_filter=ldap_group_filter,
ldap_group_name_attr=os.getenv("LDAP_GROUP_NAME_ATTR", "cn"),
ldap_group_member_attr=os.getenv("LDAP_GROUP_MEMBER_ATTR", "memberUid"),
ldap_host=ldap_host,
ldap_port=int(os.getenv("LDAP_PORT", "389")),
ldap_user_base_dn=ldap_user_base_dn,
Expand All @@ -144,4 +165,6 @@ def synchronise(
postgresql_port=int(os.getenv("POSTGRESQL_PORT", "5432")),
postgresql_user_name=postgresql_user_name,
repeat_interval=int(os.getenv("REPEAT_INTERVAL", "300")),
single_run_mode=os.getenv("SINGLE_RUN_MODE", "false").lower() == "true",
)
sys.exit(exit_code)