This is an automated email from the ASF dual-hosted git repository. sbp pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 809056b0352a2a970a4da45b0b15e74be9b4547d Author: Sean B. Palmer <[email protected]> AuthorDate: Fri Jan 23 14:24:59 2026 +0000 Add an LDAP search that discovers admin users --- atr/ldap.py | 50 +++++++++++++++++++++++++++++++++--- tests/unit/test_ldap.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/atr/ldap.py b/atr/ldap.py index 403c05b..8cc429d 100644 --- a/atr/ldap.py +++ b/atr/ldap.py @@ -14,6 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. + import asyncio import collections import dataclasses @@ -23,8 +24,10 @@ import ldap3 import ldap3.utils.conv as conv import ldap3.utils.dn as dn +LDAP_ROOT_BASE: Final[str] = "cn=infrastructure-root,ou=groups,ou=services,dc=apache,dc=org" LDAP_SEARCH_BASE: Final[str] = "ou=people,dc=apache,dc=org" LDAP_SERVER_HOST: Final[str] = "ldap-eu.apache.org" +LDAP_TOOLING_BASE: Final[str] = "cn=tooling,ou=groups,ou=services,dc=apache,dc=org" class Search: @@ -94,6 +97,48 @@ class SearchParameters: email_only: bool = False +async def fetch_admin_users() -> frozenset[str]: + import atr.log as log + + credentials = get_bind_credentials() + if credentials is None: + log.warning("LDAP bind DN or password not configured, returning empty admin set") + return frozenset() + + bind_dn, bind_password = credentials + + def _query_ldap() -> frozenset[str]: + users: set[str] = set() + with Search(bind_dn, bind_password) as ldap_search: + for base in (LDAP_ROOT_BASE, LDAP_TOOLING_BASE): + try: + result = ldap_search.search(ldap_base=base, ldap_scope="BASE") + if (not result) or (len(result) != 1): + continue + members = result[0].get("member", []) + if not isinstance(members, list): + continue + for member_dn in members: + parsed = parse_dn(member_dn) + uids = parsed.get("uid", []) + if uids: + users.add(uids[0]) + except Exception as e: + log.warning(f"Failed to query LDAP group {base}: {e}") + return frozenset(users) + + return await asyncio.to_thread(_query_ldap) + + +def get_bind_credentials() -> tuple[str, str] | None: + import atr.config as config + + conf = config.get() + if conf.LDAP_BIND_DN and conf.LDAP_BIND_PASSWORD: + return (conf.LDAP_BIND_DN, conf.LDAP_BIND_PASSWORD) + return None + + async def github_to_apache(github_numeric_uid: int) -> str: import atr.config as config @@ -116,9 +161,8 @@ async def github_to_apache(github_numeric_uid: int) -> str: def parse_dn(dn_string: str) -> dict[str, list[str]]: parsed = collections.defaultdict(list) parts = dn.parse_dn(dn_string) - for part in parts: - for attr, value in part: - parsed[attr].append(value) + for attr, value, _ in parts: + parsed[attr].append(value) return dict(parsed) diff --git a/tests/unit/test_ldap.py b/tests/unit/test_ldap.py new file mode 100644 index 0000000..5a51481 --- /dev/null +++ b/tests/unit/test_ldap.py @@ -0,0 +1,68 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import pytest + +import atr.ldap as ldap + + [email protected] +def ldap_configured() -> bool: + return ldap.get_bind_credentials() is not None + + [email protected] +async def test_fetch_admin_users_contains_only_nonempty_strings(ldap_configured: bool): + _skip_if_unavailable(ldap_configured) + admins = await ldap.fetch_admin_users() + assert all(isinstance(uid, str) and uid for uid in admins) + + [email protected] +async def test_fetch_admin_users_includes_wave(ldap_configured: bool): + _skip_if_unavailable(ldap_configured) + admins = await ldap.fetch_admin_users() + assert "wave" in admins + + [email protected] +async def test_fetch_admin_users_is_idempotent(ldap_configured: bool): + # Could, of course, fail in rare situations + _skip_if_unavailable(ldap_configured) + admins1 = await ldap.fetch_admin_users() + admins2 = await ldap.fetch_admin_users() + assert admins1 == admins2 + + [email protected] +async def test_fetch_admin_users_returns_frozenset(ldap_configured: bool): + _skip_if_unavailable(ldap_configured) + admins = await ldap.fetch_admin_users() + assert isinstance(admins, frozenset) + + [email protected] +async def test_fetch_admin_users_returns_reasonable_count(ldap_configured: bool): + _skip_if_unavailable(ldap_configured) + admins = await ldap.fetch_admin_users() + assert len(admins) > 1 + assert len(admins) < 100 + + +def _skip_if_unavailable(ldap_configured: bool) -> None: + if not ldap_configured: + pytest.skip("LDAP not configured") --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
