From b783ff66898108382a4e69b3ead6528904c1d307 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Fri, 11 Jul 2025 16:04:18 +0200 Subject: [PATCH 1/9] feature: initial implementation of LDAP/AD sync --- rhodecode/authentication/plugins/auth_ldap.py | 253 +++--------------- .../plugins/services/__init__.py | 0 .../plugins/services/ldap_dao.py | 226 ++++++++++++++++ 3 files changed, 267 insertions(+), 212 deletions(-) create mode 100644 rhodecode/authentication/plugins/services/__init__.py create mode 100644 rhodecode/authentication/plugins/services/ldap_dao.py diff --git a/rhodecode/authentication/plugins/auth_ldap.py b/rhodecode/authentication/plugins/auth_ldap.py index 5ba7866e..74a6c4fd 100644 --- a/rhodecode/authentication/plugins/auth_ldap.py +++ b/rhodecode/authentication/plugins/auth_ldap.py @@ -21,28 +21,22 @@ RhodeCode authentication plugin for LDAP """ import logging -import traceback import colander + +from rhodecode.authentication.plugins.services.ldap_dao import LdapDao from rhodecode.translation import _ -from rhodecode.authentication.base import RhodeCodeExternalAuthPlugin, AuthLdapBase, hybrid_property +from rhodecode.authentication.base import RhodeCodeExternalAuthPlugin, hybrid_property from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase, TwoFactorAuthnPluginSettingsSchemaMixin from rhodecode.authentication.routes import AuthnPluginResourceBase from rhodecode.lib.colander_utils import strip_whitespace -from rhodecode.lib.exceptions import LdapConnectionError, LdapUsernameError, LdapPasswordError, LdapImportError +from rhodecode.lib.exceptions import LdapUsernameError, LdapPasswordError, LdapImportError from rhodecode.lib.str_utils import safe_str from rhodecode.model.db import User -from rhodecode.model.validators import Missing + log = logging.getLogger(__name__) -try: - import ldap -except ImportError: - # means that python-ldap is not installed, we use Missing object to mark - # ldap lib is Missing - ldap = Missing - class LdapError(Exception): pass @@ -61,188 +55,6 @@ class LdapAuthnResource(AuthnPluginResourceBase): pass -class AuthLdap(AuthLdapBase): - default_tls_cert_dir = "/etc/openldap/cacerts" - - scope_labels = { - ldap.SCOPE_BASE: "SCOPE_BASE", - ldap.SCOPE_ONELEVEL: "SCOPE_ONELEVEL", - ldap.SCOPE_SUBTREE: "SCOPE_SUBTREE", - } - - def __init__( - self, - server, - base_dn, - port=389, - bind_dn="", - bind_pass="", - tls_kind="PLAIN", - tls_reqcert="DEMAND", - tls_cert_file=None, - tls_cert_dir=None, - ldap_version=3, - search_scope="SUBTREE", - attr_login="uid", - ldap_filter="", - timeout=None, - ): - if ldap == Missing: - raise LdapImportError("Missing or incompatible ldap library") - - self.debug = False - self.timeout = timeout or 60 * 5 - self.ldap_version = ldap_version - self.ldap_server_type = "ldap" - - self.TLS_KIND = tls_kind - - if self.TLS_KIND == "LDAPS": - port = port or 636 - self.ldap_server_type += "s" - - OPT_X_TLS_DEMAND = 2 - self.TLS_REQCERT = getattr(ldap, "OPT_X_TLS_%s" % tls_reqcert, OPT_X_TLS_DEMAND) - self.TLS_CERT_FILE = tls_cert_file or "" - self.TLS_CERT_DIR = tls_cert_dir or self.default_tls_cert_dir - - # split server into list - self.SERVER_ADDRESSES = self._get_server_list(server) - self.LDAP_SERVER_PORT = port - - # USE FOR READ ONLY BIND TO LDAP SERVER - self.attr_login = attr_login - - self.LDAP_BIND_DN = safe_str(bind_dn) - self.LDAP_BIND_PASS = safe_str(bind_pass) - - self.SEARCH_SCOPE = getattr(ldap, "SCOPE_%s" % search_scope) - self.BASE_DN = safe_str(base_dn) - self.LDAP_FILTER = safe_str(ldap_filter) - - def _get_ldap_conn(self): - if self.debug: - ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255) - - if self.TLS_CERT_FILE and hasattr(ldap, "OPT_X_TLS_CACERTFILE"): - ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, self.TLS_CERT_FILE) - - elif hasattr(ldap, "OPT_X_TLS_CACERTDIR"): - ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, self.TLS_CERT_DIR) - - if self.TLS_KIND != "PLAIN": - ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT) - - ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF) - ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON) - - # init connection now - ldap_servers = self._build_servers(self.ldap_server_type, self.SERVER_ADDRESSES, self.LDAP_SERVER_PORT) - log.debug("initializing LDAP connection to:%s", ldap_servers) - ldap_conn = ldap.initialize(ldap_servers) - ldap_conn.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout) - ldap_conn.set_option(ldap.OPT_TIMEOUT, self.timeout) - ldap_conn.timeout = self.timeout - - if self.ldap_version == 2: - ldap_conn.protocol = ldap.VERSION2 - else: - ldap_conn.protocol = ldap.VERSION3 - - if self.TLS_KIND == "START_TLS": - ldap_conn.start_tls_s() - - if self.LDAP_BIND_DN and self.LDAP_BIND_PASS: - log.debug("Trying simple_bind with password and given login DN: %r", self.LDAP_BIND_DN) - ldap_conn.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS) - log.debug("simple_bind successful") - return ldap_conn - - def fetch_attrs_from_simple_bind(self, ldap_conn, dn, username, password): - scope = ldap.SCOPE_BASE - scope_label = self.scope_labels.get(scope) - ldap_filter = "(objectClass=*)" - - try: - log.debug( - "Trying authenticated search bind with dn: %r SCOPE: %s (and filter: %s)", dn, scope_label, ldap_filter - ) - ldap_conn.simple_bind_s(dn, safe_str(password)) - response = ldap_conn.search_ext_s(dn, scope, ldap_filter, attrlist=["*", "+"]) - - if not response: - log.error("search bind returned empty results: %r", response) - return {} - else: - _dn, attrs = response[0] - return attrs - - except ldap.INVALID_CREDENTIALS: - log.debug("LDAP rejected password for user '%s': %s, org_exc:", username, dn, exc_info=True) - - def authenticate_ldap(self, username, password): - """ - Authenticate a user via LDAP and return his/her LDAP properties. - - Raises AuthenticationError if the credentials are rejected, or - EnvironmentError if the LDAP server can't be reached. - - :param username: username - :param password: password - """ - - uid = self.get_uid(username, self.SERVER_ADDRESSES) - user_attrs = {} - dn = "" - - self.validate_password(username, password) - self.validate_username(username) - scope_label = self.scope_labels.get(self.SEARCH_SCOPE) - - ldap_conn = None - try: - ldap_conn = self._get_ldap_conn() - filter_ = "(&{}({}={}))".format(self.LDAP_FILTER, self.attr_login, username) - log.debug("Authenticating %r filter %s and scope: %s", self.BASE_DN, filter_, scope_label) - - ldap_objects = ldap_conn.search_ext_s(self.BASE_DN, self.SEARCH_SCOPE, filter_, attrlist=["*", "+"]) - - if not ldap_objects: - log.debug("No matching LDAP objects for authentication of UID:'%s' username:(%s)", uid, username) - raise ldap.NO_SUCH_OBJECT() - - log.debug("Found %s matching ldap object[s], trying to authenticate on each one now...", len(ldap_objects)) - for dn, _attrs in ldap_objects: - if dn is None: - continue - - user_attrs = self.fetch_attrs_from_simple_bind(ldap_conn, dn, username, password) - - if user_attrs: - log.debug("Got authenticated user attributes from DN:%s", dn) - break - else: - raise LdapPasswordError(f"Failed to authenticate user `{username}` with given password") - - except ldap.NO_SUCH_OBJECT: - log.debug("LDAP says no such user '%s' (%s), org_exc:", uid, username, exc_info=True) - raise LdapUsernameError("Unable to find user") - except ldap.SERVER_DOWN: - org_exc = traceback.format_exc() - raise LdapConnectionError("LDAP can't access authentication server, org_exc:%s" % org_exc) - finally: - if ldap_conn: - log.debug("ldap: connection release") - try: - ldap_conn.unbind_s() - except Exception: - # for any reason this can raise exception we must catch it - # to not crush the server - pass - - return dn, user_attrs - - class LdapSettingsSchema(TwoFactorAuthnPluginSettingsSchemaMixin, AuthnPluginSettingsSchemaBase): tls_kind_choices = ["PLAIN", "LDAPS", "START_TLS"] tls_reqcert_choices = ["NEVER", "ALLOW", "TRY", "DEMAND", "HARD"] @@ -338,7 +150,7 @@ class LdapSettingsSchema(TwoFactorAuthnPluginSettingsSchemaMixin, AuthnPluginSet ) tls_cert_dir = colander.SchemaNode( colander.String(), - default=AuthLdap.default_tls_cert_dir, + default=LdapDao.default_tls_cert_dir, description=_( "This specifies the path of a directory that contains individual CA certificates in separate files." ), @@ -420,6 +232,38 @@ class LdapSettingsSchema(TwoFactorAuthnPluginSettingsSchemaMixin, AuthnPluginSet title=_("Last Name Attribute"), widget="string", ) + sync_active_directory_users = colander.SchemaNode( + colander.Bool(), + default=False, + description=_( + "A cron job that periodically retrieves all users from an LDAP-based Active Directory server and syncs " + "them with the RhodeCode database.\n" + "Note: This feature is specific to Active Directory. Enabling it for other types of LDAP servers will have no effect." + ), + missing=False, + preparer=strip_whitespace, + title=_("Sync Active Directory Users"), + widget="bool", + ) + + +def get_ldap_args(settings: dict): + return { + "server": settings.get("host", ""), + "base_dn": settings.get("base_dn", ""), + "port": settings.get("port"), + "bind_dn": settings.get("dn_user"), + "bind_pass": settings.get("dn_pass"), + "tls_kind": settings.get("tls_kind"), + "tls_reqcert": settings.get("tls_reqcert"), + "tls_cert_file": settings.get("tls_cert_file"), + "tls_cert_dir": settings.get("tls_cert_dir"), + "search_scope": settings.get("search_scope"), + "attr_login": settings.get("attr_login"), + "ldap_version": 3, + "ldap_filter": settings.get("filter"), + "timeout": settings.get("timeout"), + } class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin): @@ -509,30 +353,15 @@ class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin): log.debug("Empty username or password skipping...") return None - ldap_args = { - "server": settings.get("host", ""), - "base_dn": settings.get("base_dn", ""), - "port": settings.get("port"), - "bind_dn": settings.get("dn_user"), - "bind_pass": settings.get("dn_pass"), - "tls_kind": settings.get("tls_kind"), - "tls_reqcert": settings.get("tls_reqcert"), - "tls_cert_file": settings.get("tls_cert_file"), - "tls_cert_dir": settings.get("tls_cert_dir"), - "search_scope": settings.get("search_scope"), - "attr_login": settings.get("attr_login"), - "ldap_version": 3, - "ldap_filter": settings.get("filter"), - "timeout": settings.get("timeout"), - } + ldap_args = get_ldap_args(settings) ldap_attrs = self.try_dynamic_binding(username, password, ldap_args) log.debug("Checking for ldap authentication.") try: - auth_ldap = AuthLdap(**ldap_args) - (user_dn, ldap_attrs) = auth_ldap.authenticate_ldap(username, password) + ldap_dao = LdapDao(**ldap_args) + (user_dn, ldap_attrs) = ldap_dao.authenticate_ldap(username, password) log.debug("Got ldap DN response %s", user_dn) def get_ldap_attr(k) -> str: diff --git a/rhodecode/authentication/plugins/services/__init__.py b/rhodecode/authentication/plugins/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rhodecode/authentication/plugins/services/ldap_dao.py b/rhodecode/authentication/plugins/services/ldap_dao.py new file mode 100644 index 00000000..7a53e961 --- /dev/null +++ b/rhodecode/authentication/plugins/services/ldap_dao.py @@ -0,0 +1,226 @@ +import logging +import traceback +from dataclasses import dataclass +from typing import List, Optional + +from rhodecode.model.validators import Missing +from rhodecode.lib.exceptions import LdapConnectionError, LdapUsernameError, LdapPasswordError, LdapImportError +from rhodecode.authentication.base import AuthLdapBase +from rhodecode.lib.str_utils import safe_str + +try: + import ldap +except ImportError: + # means that python-ldap is not installed, we use Missing object to mark + # ldap lib is Missing + ldap = Missing + + +@dataclass +class UserType: + DEFAULT: str = "DEFAULT" + ACTIVE_DIRECTORY: str = "ACTIVE_DIRECTORY" + + +class LdapDao(AuthLdapBase): + default_tls_cert_dir = "/etc/openldap/cacerts" + + scope_labels = { + ldap.SCOPE_BASE: "SCOPE_BASE", + ldap.SCOPE_ONELEVEL: "SCOPE_ONELEVEL", + ldap.SCOPE_SUBTREE: "SCOPE_SUBTREE", + } + + def __init__( + self, + server, + base_dn, + port=389, + bind_dn="", + bind_pass="", + tls_kind="PLAIN", + tls_reqcert="DEMAND", + tls_cert_file=None, + tls_cert_dir=None, + ldap_version=3, + search_scope="SUBTREE", + attr_login="uid", + ldap_filter="", + timeout=None, + ): + if ldap == Missing: + raise LdapImportError("Missing or incompatible ldap library") + + self.log = logging.getLogger(LdapDao.__name__) + + self.debug = False + self.timeout = timeout or 60 * 5 + self.ldap_version = ldap_version + self.ldap_server_type = "ldap" + + self.TLS_KIND = tls_kind + + if self.TLS_KIND == "LDAPS": + port = port or 636 + self.ldap_server_type += "s" + + OPT_X_TLS_DEMAND = 2 + self.TLS_REQCERT = getattr(ldap, "OPT_X_TLS_%s" % tls_reqcert, OPT_X_TLS_DEMAND) + self.TLS_CERT_FILE = tls_cert_file or "" + self.TLS_CERT_DIR = tls_cert_dir or self.default_tls_cert_dir + + # split server into list + self.SERVER_ADDRESSES = self._get_server_list(server) + self.LDAP_SERVER_PORT = port + + # USE FOR READ ONLY BIND TO LDAP SERVER + self.attr_login = attr_login + + self.LDAP_BIND_DN = safe_str(bind_dn) + self.LDAP_BIND_PASS = safe_str(bind_pass) + + self.SEARCH_SCOPE = getattr(ldap, "SCOPE_%s" % search_scope) + self.BASE_DN = safe_str(base_dn) + self.LDAP_FILTER = safe_str(ldap_filter) + + def _get_ldap_conn(self): + if self.debug: + ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255) + + if self.TLS_CERT_FILE and hasattr(ldap, "OPT_X_TLS_CACERTFILE"): + ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, self.TLS_CERT_FILE) + + elif hasattr(ldap, "OPT_X_TLS_CACERTDIR"): + ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, self.TLS_CERT_DIR) + + if self.TLS_KIND != "PLAIN": + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT) + + ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF) + ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON) + + # init connection now + ldap_servers = self._build_servers(self.ldap_server_type, self.SERVER_ADDRESSES, self.LDAP_SERVER_PORT) + self.log.debug("initializing LDAP connection to:%s", ldap_servers) + ldap_conn = ldap.initialize(ldap_servers) + ldap_conn.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout) + ldap_conn.set_option(ldap.OPT_TIMEOUT, self.timeout) + ldap_conn.timeout = self.timeout + + if self.ldap_version == 2: + ldap_conn.protocol = ldap.VERSION2 + else: + ldap_conn.protocol = ldap.VERSION3 + + if self.TLS_KIND == "START_TLS": + ldap_conn.start_tls_s() + + if self.LDAP_BIND_DN and self.LDAP_BIND_PASS: + self.log.debug("Trying simple_bind with password and given login DN: %r", self.LDAP_BIND_DN) + ldap_conn.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS) + self.log.debug("simple_bind successful") + return ldap_conn + + def fetch_all(self, ldap_filter: str = "(objectClass=*)", attributes: List[str] = None) -> Optional[List[dict]]: + # TODO: add pagination + ldap_conn = None + try: + if attributes is None: + attributes = ["*", "+"] + + ldap_conn = self._get_ldap_conn() + self.log.debug("fetching users for DN: %s", self.BASE_DN) + ldap_objects = ldap_conn.search_ext_s(self.BASE_DN, self.SEARCH_SCOPE, ldap_filter, attrlist=attributes) + return [attrs for _, attrs in ldap_objects] + except Exception as e: + self.log.error("Error fetching users for DN: %s. Error: %s", self.BASE_DN, str(e)) + return None + finally: + self._releease_connection(ldap_conn) + + def _releease_connection(self, ldap_conn): + if ldap_conn: + self.log.debug("ldap: connection release") + try: + ldap_conn.unbind_s() + except Exception as e: + # for any reason this can raise exception we must catch it + # to not crush the server + self.log.warning("unbind_s failed, error: %s", str(e)) + + def _fetch_attrs_from_simple_bind(self, ldap_conn, dn, username, password): + scope = ldap.SCOPE_BASE + scope_label = self.scope_labels.get(scope) + ldap_filter = "(objectClass=*)" + + try: + self.log.debug( + "Trying authenticated search bind with dn: %r SCOPE: %s (and filter: %s)", dn, scope_label, ldap_filter + ) + ldap_conn.simple_bind_s(dn, safe_str(password)) + response = ldap_conn.search_ext_s(dn, scope, ldap_filter, attrlist=["*", "+"]) + + if not response: + self.log.error("search bind returned empty results: %r", response) + return {} + else: + _dn, attrs = response[0] + return attrs + + except ldap.INVALID_CREDENTIALS: + self.log.debug("LDAP rejected password for user '%s': %s, org_exc:", username, dn, exc_info=True) + + def authenticate_ldap(self, username, password): + """ + Authenticate a user via LDAP and return his/her LDAP properties. + + Raises AuthenticationError if the credentials are rejected, or + EnvironmentError if the LDAP server can't be reached. + + :param username: username + :param password: password + """ + + uid = self.get_uid(username, self.SERVER_ADDRESSES) + + self.validate_password(username, password) + self.validate_username(username) + scope_label = self.scope_labels.get(self.SEARCH_SCOPE) + + ldap_conn = None + try: + ldap_conn = self._get_ldap_conn() + filter_ = "(&{}({}={}))".format(self.LDAP_FILTER, self.attr_login, username) + self.log.debug("Authenticating %r filter %s and scope: %s", self.BASE_DN, filter_, scope_label) + + ldap_objects = ldap_conn.search_ext_s(self.BASE_DN, self.SEARCH_SCOPE, filter_, attrlist=["*", "+"]) + + if not ldap_objects: + self.log.debug("No matching LDAP objects for authentication of UID:'%s' username:(%s)", uid, username) + raise ldap.NO_SUCH_OBJECT() + + self.log.debug( + "Found %s matching ldap object[s], trying to authenticate on each one now...", len(ldap_objects) + ) + for dn, _attrs in ldap_objects: + if dn is None: + continue + + user_attrs = self._fetch_attrs_from_simple_bind(ldap_conn, dn, username, password) + + if user_attrs: + self.log.debug("Got authenticated user attributes from DN:%s", dn) + break + else: + raise LdapPasswordError(f"Failed to authenticate user `{username}` with given password") + + except ldap.NO_SUCH_OBJECT: + self.log.debug("LDAP says no such user '%s' (%s), org_exc:", uid, username, exc_info=True) + raise LdapUsernameError("Unable to find user") + except ldap.SERVER_DOWN: + org_exc = traceback.format_exc() + raise LdapConnectionError("LDAP can't access authentication server, org_exc:%s" % org_exc) + finally: + self._releease_connection(ldap_conn) + + return dn, user_attrs From c0201a85a8901ac0ba509069d24a827465decf14 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Mon, 14 Jul 2025 12:18:05 +0200 Subject: [PATCH 2/9] feature: adds schedule/unschedule AD users sync. --- rhodecode/authentication/views.py | 15 +++++++++++ rhodecode/lib/celerylib/tasks.py | 44 ++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/rhodecode/authentication/views.py b/rhodecode/authentication/views.py index 6b4bb01b..033f9fcf 100644 --- a/rhodecode/authentication/views.py +++ b/rhodecode/authentication/views.py @@ -28,6 +28,7 @@ from rhodecode.apps._base import BaseAppView from rhodecode.authentication.base import get_authn_registry from rhodecode.lib import helpers as h from rhodecode.lib.auth import LoginRequired, HasPermissionAllDecorator, CSRFRequired +from rhodecode.lib.celerylib import run_task, tasks from rhodecode.model.forms import AuthSettingsForm from rhodecode.model.meta import Session from rhodecode.model.settings import SettingsModel @@ -90,6 +91,9 @@ class AuthnPluginViewBase(BaseAppView): # Store validated data. for name, value in valid_data.items(): self.plugin.create_or_update_setting(name, value) + if name == "sync_active_directory_users": + self._handle_ad_users_sync_schedule(name, value) + Session().commit() SettingsModel().invalidate_settings_cache() @@ -102,6 +106,17 @@ class AuthnPluginViewBase(BaseAppView): return HTTPFound(redirect_to) + def _handle_ad_users_sync_schedule(self, name: str, enabled: bool): + if name != "sync_active_directory_users": + return + + if enabled: + log.debug("Scheduling AD users sync") + run_task(tasks.schedule_sync_ldap_ad_users_producer) + else: + log.debug("Removing schedule for AD users sync") + run_task(tasks.unschedule_sync_ldap_ad_users_producer) + class AuthSettingsView(BaseAppView): def load_default_context(self): diff --git a/rhodecode/lib/celerylib/tasks.py b/rhodecode/lib/celerylib/tasks.py index 8da73f67..943e16bf 100644 --- a/rhodecode/lib/celerylib/tasks.py +++ b/rhodecode/lib/celerylib/tasks.py @@ -24,6 +24,7 @@ by celery daemon import os import time +from celery import current_app from pyramid_mailer.mailer import Mailer from pyramid_mailer.message import Message from email.utils import formatdate @@ -35,7 +36,7 @@ from rhodecode.lib import hooks_base from rhodecode.lib.utils import adopt_for_celery from rhodecode.lib.utils2 import safe_int, str2bool, aslist from rhodecode.lib.statsd_client import StatsdClient -from rhodecode.model.db import true, null, Session, IntegrityError, Repository, RepoGroup, User +from rhodecode.model.db import true, null, Session, IntegrityError, Repository, RepoGroup, User, ScheduleEntry from rhodecode.model.permission import PermissionModel @@ -417,6 +418,47 @@ def beat_check(*args, **kwargs): return time.time() +@async_task(ignore_result=True) +def schedule_sync_ldap_ad_users_producer(): + log = get_logger(schedule_sync_ldap_ad_users_producer) + + try: + from rc_ee.lib.celerylib.scheduler import RcScheduler + except ImportError: + log.error("Attempt to schedule EE feature") + return + + scheduler = RcScheduler(app=current_app) + scheduler.sync() + task_name = "rc_ee.lib.celerylib.tasks.sync_ldap_ad_users_producer" + if task_name not in scheduler.schedule: + entries = { + task_name: { + "task": task_name, + "schedule_type": "crontab", + "schedule_value": {"hour": 0, "minute": 4}, + "options": {"expires": 12 * 3600}, + } + } + scheduler.update_from_dict(entries) + + +@async_task(ignore_result=True) +def unschedule_sync_ldap_ad_users_producer(): + log = get_logger(unschedule_sync_ldap_ad_users_producer) + + try: + from rc_ee.lib.celerylib.scheduler import RcScheduler + except ImportError: + log.error("Attempt to unschedule EE feature") + return + + task_name = "rc_ee.lib.celerylib.tasks.sync_ldap_ad_users_producer" + existing_task = ScheduleEntry.query().filter(ScheduleEntry.task_dot_notation == task_name).first() + if existing_task: + Session().delete(existing_task) + + @async_task @adopt_for_celery def repo_size(extras): From 736a4411867b5625b2e7f44b69e83760b113fbaa Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Mon, 14 Jul 2025 12:19:25 +0200 Subject: [PATCH 3/9] feature: removes redundant class --- rhodecode/authentication/plugins/services/ldap_dao.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/rhodecode/authentication/plugins/services/ldap_dao.py b/rhodecode/authentication/plugins/services/ldap_dao.py index 7a53e961..46db1b8f 100644 --- a/rhodecode/authentication/plugins/services/ldap_dao.py +++ b/rhodecode/authentication/plugins/services/ldap_dao.py @@ -16,12 +16,6 @@ except ImportError: ldap = Missing -@dataclass -class UserType: - DEFAULT: str = "DEFAULT" - ACTIVE_DIRECTORY: str = "ACTIVE_DIRECTORY" - - class LdapDao(AuthLdapBase): default_tls_cert_dir = "/etc/openldap/cacerts" From c4e8a9c7cc1f6d1eab2b459471938af14893d23f Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Wed, 16 Jul 2025 13:10:38 +0200 Subject: [PATCH 4/9] tests: adds tests for ldap dao --- .../plugins/services/ldap_dao.py | 1 - .../authentication/tests/services/__init__.py | 0 .../tests/services/test_ldap_dao.py | 41 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 rhodecode/authentication/tests/services/__init__.py create mode 100644 rhodecode/authentication/tests/services/test_ldap_dao.py diff --git a/rhodecode/authentication/plugins/services/ldap_dao.py b/rhodecode/authentication/plugins/services/ldap_dao.py index 46db1b8f..1c18c239 100644 --- a/rhodecode/authentication/plugins/services/ldap_dao.py +++ b/rhodecode/authentication/plugins/services/ldap_dao.py @@ -116,7 +116,6 @@ class LdapDao(AuthLdapBase): return ldap_conn def fetch_all(self, ldap_filter: str = "(objectClass=*)", attributes: List[str] = None) -> Optional[List[dict]]: - # TODO: add pagination ldap_conn = None try: if attributes is None: diff --git a/rhodecode/authentication/tests/services/__init__.py b/rhodecode/authentication/tests/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/rhodecode/authentication/tests/services/test_ldap_dao.py b/rhodecode/authentication/tests/services/test_ldap_dao.py new file mode 100644 index 00000000..1de4bf42 --- /dev/null +++ b/rhodecode/authentication/tests/services/test_ldap_dao.py @@ -0,0 +1,41 @@ +import unittest +from unittest.mock import patch, MagicMock + +import ldap + +from rhodecode.authentication.plugins.services.ldap_dao import LdapDao +from rhodecode.lib.diff_match_patch import patch_obj + + +@patch("rhodecode.authentication.plugins.services.ldap_dao.ldap") +class TestLdapDao(unittest.TestCase): + def setUp(self): + self._server_list = "test_srv1,test_srv2,test_srv3" + self._base_dn = "test_dn" + self.ldap_dao = LdapDao( + server=self._server_list, + base_dn=self._base_dn, + ) + + @patch.object(LdapDao, "_get_ldap_conn") + def test_fetch_all_from_ldap_server(self, _get_ldap_conn_mock, ldap_mock): + conn = MagicMock() + no_objects_in_ldap_server = [] + conn.search_ext_s.return_value = no_objects_in_ldap_server + + _get_ldap_conn_mock.return_value = conn + + returned_value = self.ldap_dao.fetch_all() + + conn.search_ext_s.assert_called_once_with(self._base_dn, 2, "(objectClass=*)", attrlist=["*", "+"]) + + assert returned_value == no_objects_in_ldap_server + + @patch.object(LdapDao, "_get_ldap_conn") + def test_fetch_all_release_connection(self, _get_ldap_conn_mock, ldap_mock): + conn = MagicMock() + _get_ldap_conn_mock.return_value = conn + + self.ldap_dao.fetch_all() + + conn.unbind_s.assert_called_once() From 27e90c24cd8fe62de1f1694479b59993ccc538e9 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Wed, 16 Jul 2025 13:33:26 +0200 Subject: [PATCH 5/9] tests: adds fail test --- .../authentication/tests/services/test_ldap_dao.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/rhodecode/authentication/tests/services/test_ldap_dao.py b/rhodecode/authentication/tests/services/test_ldap_dao.py index 1de4bf42..cceb9f6f 100644 --- a/rhodecode/authentication/tests/services/test_ldap_dao.py +++ b/rhodecode/authentication/tests/services/test_ldap_dao.py @@ -39,3 +39,14 @@ class TestLdapDao(unittest.TestCase): self.ldap_dao.fetch_all() conn.unbind_s.assert_called_once() + + @patch.object(LdapDao, "_get_ldap_conn") + def test_fetch_all_release_connection_on_error(self, _get_ldap_conn_mock, ldap_mock): + conn = MagicMock() + _get_ldap_conn_mock.return_value = conn + conn.search_ext_s.side_effect = Exception("Test exception") + + res = self.ldap_dao.fetch_all() + + assert res is None + conn.unbind_s.assert_called_once() From 77a339f82db3822fdde2aff04b860511de8f8c6a Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Wed, 16 Jul 2025 15:23:26 +0200 Subject: [PATCH 6/9] tests: adds test to validate calling schedule unschedule tasks --- .../tests/test_auth_plugin_view.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 rhodecode/authentication/tests/test_auth_plugin_view.py diff --git a/rhodecode/authentication/tests/test_auth_plugin_view.py b/rhodecode/authentication/tests/test_auth_plugin_view.py new file mode 100644 index 00000000..0c761190 --- /dev/null +++ b/rhodecode/authentication/tests/test_auth_plugin_view.py @@ -0,0 +1,63 @@ +import unittest +from unittest.mock import MagicMock, patch +import pytest + +from rhodecode.authentication.views import AuthnPluginViewBase +from rhodecode.lib.celerylib import tasks + + +class TestAuthPluginView: + @pytest.mark.parametrize( + "sync_active_directory_users, expected_celery_task", + [ + ( + True, + tasks.schedule_sync_ldap_ad_users_producer, + ), + ( + False, + tasks.unschedule_sync_ldap_ad_users_producer, + ), + ], + ) + @patch("rhodecode.apps._base.BaseAppView.__init__", return_value=None) + @patch("rhodecode.authentication.views.h") + @patch("rhodecode.authentication.views.SettingsModel") + @patch.object(AuthnPluginViewBase, "load_default_context") + @patch("rhodecode.authentication.views.run_task") + def test_enable_ad_sync_schedule( + self, + run_task_mock, + load_default_context, + settings_model, + h, + _init_, + sync_active_directory_users, + expected_celery_task, + ): + view = self._get_instance() + + settings_post = AuthnPluginViewBase.__dict__[ # ignore decorators + "settings_post" + ].__wrapped__.__wrapped__.__wrapped__ + + schema = MagicMock() + schema.deserialize.return_value = {"sync_active_directory_users": sync_active_directory_users} + + self._plugin.get_settings_schema.return_value = schema + + settings_post(view) + + run_task_mock.assert_called_once_with(expected_celery_task) + + def _get_instance(self): + # since base __init__ was patched, there is a need to inject mocks manually + self._context = MagicMock() + self._request = MagicMock() + self._plugin = MagicMock() + + view = AuthnPluginViewBase(self._context, self._request) + view.request = self._request + view.plugin = self._plugin + view.context = self._context + return view From 7577eeb49387e43b812620404deaffbde0a5cc75 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Fri, 11 Jul 2025 16:04:18 +0200 Subject: [PATCH 7/9] feature: initial implementation of LDAP/AD sync --- rhodecode/authentication/plugins/services/ldap_dao.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/rhodecode/authentication/plugins/services/ldap_dao.py b/rhodecode/authentication/plugins/services/ldap_dao.py index 1c18c239..7a53e961 100644 --- a/rhodecode/authentication/plugins/services/ldap_dao.py +++ b/rhodecode/authentication/plugins/services/ldap_dao.py @@ -16,6 +16,12 @@ except ImportError: ldap = Missing +@dataclass +class UserType: + DEFAULT: str = "DEFAULT" + ACTIVE_DIRECTORY: str = "ACTIVE_DIRECTORY" + + class LdapDao(AuthLdapBase): default_tls_cert_dir = "/etc/openldap/cacerts" @@ -116,6 +122,7 @@ class LdapDao(AuthLdapBase): return ldap_conn def fetch_all(self, ldap_filter: str = "(objectClass=*)", attributes: List[str] = None) -> Optional[List[dict]]: + # TODO: add pagination ldap_conn = None try: if attributes is None: From 5c64efb55f2b7480f08777ef8e35aa9238fc0ddc Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Mon, 14 Jul 2025 12:19:25 +0200 Subject: [PATCH 8/9] feature: removes redundant class --- rhodecode/authentication/plugins/services/ldap_dao.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/rhodecode/authentication/plugins/services/ldap_dao.py b/rhodecode/authentication/plugins/services/ldap_dao.py index 7a53e961..46db1b8f 100644 --- a/rhodecode/authentication/plugins/services/ldap_dao.py +++ b/rhodecode/authentication/plugins/services/ldap_dao.py @@ -16,12 +16,6 @@ except ImportError: ldap = Missing -@dataclass -class UserType: - DEFAULT: str = "DEFAULT" - ACTIVE_DIRECTORY: str = "ACTIVE_DIRECTORY" - - class LdapDao(AuthLdapBase): default_tls_cert_dir = "/etc/openldap/cacerts" From 60628d44266518b93b20cf21568b6be273ad9175 Mon Sep 17 00:00:00 2001 From: ievgenii vdovenko Date: Wed, 16 Jul 2025 13:10:38 +0200 Subject: [PATCH 9/9] tests: adds tests for ldap dao --- rhodecode/authentication/plugins/services/ldap_dao.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rhodecode/authentication/plugins/services/ldap_dao.py b/rhodecode/authentication/plugins/services/ldap_dao.py index 46db1b8f..1c18c239 100644 --- a/rhodecode/authentication/plugins/services/ldap_dao.py +++ b/rhodecode/authentication/plugins/services/ldap_dao.py @@ -116,7 +116,6 @@ class LdapDao(AuthLdapBase): return ldap_conn def fetch_all(self, ldap_filter: str = "(objectClass=*)", attributes: List[str] = None) -> Optional[List[dict]]: - # TODO: add pagination ldap_conn = None try: if attributes is None: