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


The following commit(s) were added to refs/heads/main by this push:
     new d885221  Use the LDAP admins cache when checking whether the user is 
an admin
d885221 is described below

commit d8852212d123674e8dcaa62e9b6f48b757c86086
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Jan 23 16:43:18 2026 +0000

    Use the LDAP admins cache when checking whether the user is an admin
---
 atr/blueprints/admin.py   |   2 +-
 atr/cache.py              |  15 +++++++
 atr/config.py             |  15 -------
 atr/tasks/checks/paths.py |   2 +-
 atr/user.py               |  34 ++++++++++-----
 tests/unit/test_cache.py  |  32 ++++++++++++++
 tests/unit/test_user.py   | 107 ++++++++++++++++++++++++++++++++++++++++++++++
 7 files changed, 179 insertions(+), 28 deletions(-)

diff --git a/atr/blueprints/admin.py b/atr/blueprints/admin.py
index ca58b77..a6f93ae 100644
--- a/atr/blueprints/admin.py
+++ b/atr/blueprints/admin.py
@@ -140,7 +140,7 @@ async def _check_admin_access() -> None:
     if web_session is None:
         raise base.ASFQuartException("Not authenticated", errorcode=401)
 
-    if web_session.uid not in user.get_admin_users():
+    if not user.is_admin(web_session.uid):
         raise base.ASFQuartException("You are not authorized to access the 
admin interface", errorcode=403)
 
     quart.g.session = web.Committer(web_session)
diff --git a/atr/cache.py b/atr/cache.py
index fef42c5..4feb61d 100644
--- a/atr/cache.py
+++ b/atr/cache.py
@@ -38,6 +38,21 @@ class AdminsCache(schema.Strict):
     admins: frozenset[str] = schema.description("Set of admin user IDs from 
LDAP")
 
 
+def admins_get() -> frozenset[str]:
+    app = asfquart.APP
+    return app.extensions.get("admins", frozenset())
+
+
+async def admins_get_async() -> frozenset[str]:
+    try:
+        return admins_get()
+    except RuntimeError:
+        cache_data = await admins_read_from_file()
+        if cache_data is None:
+            return frozenset()
+        return cache_data.admins
+
+
 async def admins_read_from_file() -> AdminsCache | None:
     cache_path = _admins_path()
     if not cache_path.exists():
diff --git a/atr/config.py b/atr/config.py
index 0fceba1..b8090c5 100644
--- a/atr/config.py
+++ b/atr/config.py
@@ -120,22 +120,7 @@ class AppConfig:
     SESSION_COOKIE_SAMESITE = "Strict"
     SESSION_COOKIE_NAME = "__Host-session"
 
-    # FIXME: retrieve the list of admin users from LDAP or oath session / 
isRoot
     ADMIN_USERS_ADDITIONAL = decouple.config("ADMIN_USERS_ADDITIONAL", 
default="", cast=str)
-    ADMIN_USERS = frozenset(
-        {
-            "cwells",
-            "dfoulks",
-            "fluxo",
-            "gmcdonald",
-            "humbedooh",
-            "sbp",
-            "akm",
-            "arm",
-            "wave",
-        }
-        | set(ADMIN_USERS_ADDITIONAL.split(",") if ADMIN_USERS_ADDITIONAL else 
[])
-    )
 
 
 class DebugConfig(AppConfig):
diff --git a/atr/tasks/checks/paths.py b/atr/tasks/checks/paths.py
index 8f82e9c..b1a5a62 100644
--- a/atr/tasks/checks/paths.py
+++ b/atr/tasks/checks/paths.py
@@ -179,7 +179,7 @@ async def _check_path_process_single(
     relative_path_str = str(relative_path)
 
     # For debugging and testing
-    if user.is_admin(asf_uid) and (full_path.name == 
"deliberately_slow_ATR_task_filename.txt"):
+    if (await user.is_admin_async(asf_uid)) and (full_path.name == 
"deliberately_slow_ATR_task_filename.txt"):
         await asyncio.sleep(20)
 
     errors: list[str] = []
diff --git a/atr/user.py b/atr/user.py
index cfb1c1d..e57e678 100644
--- a/atr/user.py
+++ b/atr/user.py
@@ -19,6 +19,7 @@
 
 import functools
 
+import atr.cache as cache
 import atr.config as config
 import atr.db as db
 import atr.models.sql as sql
@@ -37,21 +38,24 @@ async def candidate_drafts(uid: str, user_projects: 
list[sql.Project] | None = N
     return user_candidate_drafts
 
 
[email protected]
-def get_admin_users() -> set[str]:
-    admin_users = set(config.get().ADMIN_USERS)
-    if config.get().ALLOW_TESTS:
-        # TODO: Just for debugging, but ideally we would do this in a targeted 
way
-        # We need this, for example, for deleting releases
-        admin_users.add("test")
-    return admin_users
+def is_admin(user_id: str | None) -> bool:
+    if user_id is None:
+        return False
+    if config.get().ALLOW_TESTS and (user_id == "test"):
+        return True
+    if user_id in _get_additional_admin_users():
+        return True
+    return user_id in cache.admins_get()
 
 
-def is_admin(user_id: str | None) -> bool:
-    """Check whether a user is an admin."""
+async def is_admin_async(user_id: str | None) -> bool:
     if user_id is None:
         return False
-    return user_id in get_admin_users()
+    if config.get().ALLOW_TESTS and (user_id == "test"):
+        return True
+    if user_id in _get_additional_admin_users():
+        return True
+    return user_id in await cache.admins_get_async()
 
 
 def is_committee_member(committee: sql.Committee | None, uid: str) -> bool:
@@ -90,3 +94,11 @@ async def projects(uid: str, committee_only: bool = False, 
super_project: bool =
                 if (uid in p.committee.committee_members) or (uid in 
p.committee.committers):
                     user_projects.append(p)
     return user_projects
+
+
[email protected]
+def _get_additional_admin_users() -> frozenset[str]:
+    additional = config.get().ADMIN_USERS_ADDITIONAL
+    if not additional:
+        return frozenset()
+    return frozenset(additional.split(","))
diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py
index a8f962f..dec2adc 100644
--- a/tests/unit/test_cache.py
+++ b/tests/unit/test_cache.py
@@ -94,6 +94,38 @@ def test_admins_cache_validates_with_good_data():
     assert data.admins == frozenset({"alice", "bob"})
 
 
[email protected]
+async def test_admins_get_async_falls_back_to_file_without_app_context(
+    state_dir: pathlib.Path, monkeypatch: "MonkeyPatch"
+):
+    def raise_runtime_error() -> frozenset[str]:
+        raise RuntimeError("No app context")
+
+    monkeypatch.setattr("atr.cache.admins_get", raise_runtime_error)
+
+    await cache.admins_save_to_file(frozenset({"file_alice", "file_bob"}))
+    result = await cache.admins_get_async()
+    assert result == frozenset({"file_alice", "file_bob"})
+
+
[email protected]
+async def test_admins_get_async_uses_extensions_when_available(mock_app: 
_MockApp):
+    mock_app.extensions["admins"] = frozenset({"async_alice"})
+    result = await cache.admins_get_async()
+    assert result == frozenset({"async_alice"})
+
+
+def test_admins_get_returns_empty_frozenset_when_not_set(mock_app: _MockApp):
+    result = cache.admins_get()
+    assert result == frozenset()
+
+
+def test_admins_get_returns_frozenset_from_extensions(mock_app: _MockApp):
+    mock_app.extensions["admins"] = frozenset({"alice", "bob"})
+    result = cache.admins_get()
+    assert result == frozenset({"alice", "bob"})
+
+
 @pytest.mark.asyncio
 async def test_admins_read_from_file_returns_none_for_invalid_json(state_dir: 
pathlib.Path):
     cache_path = state_dir / "cache" / "admins.json"
diff --git a/tests/unit/test_user.py b/tests/unit/test_user.py
new file mode 100644
index 0000000..268b22e
--- /dev/null
+++ b/tests/unit/test_user.py
@@ -0,0 +1,107 @@
+# 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.
+
+from typing import TYPE_CHECKING
+
+import pytest
+
+import atr.user as user
+
+if TYPE_CHECKING:
+    from pytest import MonkeyPatch
+
+
+class _MockApp:
+    def __init__(self):
+        self.extensions: dict[str, object] = {}
+
+
+class _MockConfig:
+    def __init__(self, allow_tests: bool = False, admin_users_additional: str 
= ""):
+        self.ALLOW_TESTS = allow_tests
+        self.ADMIN_USERS_ADDITIONAL = admin_users_additional
+
+
[email protected]
+def mock_app(monkeypatch: "MonkeyPatch") -> _MockApp:
+    app = _MockApp()
+    monkeypatch.setattr("asfquart.APP", app)
+    return app
+
+
[email protected]
+async def test_is_admin_async_returns_false_for_none(mock_app: _MockApp, 
monkeypatch: "MonkeyPatch"):
+    monkeypatch.setattr("atr.config.get", lambda: _MockConfig())
+    mock_app.extensions["admins"] = frozenset()
+    assert await user.is_admin_async(None) is False
+
+
[email protected]
+async def test_is_admin_async_returns_true_for_cached_admin(mock_app: 
_MockApp, monkeypatch: "MonkeyPatch"):
+    user._get_additional_admin_users.cache_clear()
+    monkeypatch.setattr("atr.config.get", lambda: _MockConfig())
+    mock_app.extensions["admins"] = frozenset({"async_admin"})
+    assert await user.is_admin_async("async_admin") is True
+
+
[email protected]
+async def test_is_admin_async_returns_true_for_test_user(mock_app: _MockApp, 
monkeypatch: "MonkeyPatch"):
+    user._get_additional_admin_users.cache_clear()
+    monkeypatch.setattr("atr.config.get", lambda: 
_MockConfig(allow_tests=True))
+    mock_app.extensions["admins"] = frozenset()
+    assert await user.is_admin_async("test") is True
+
+
+def test_is_admin_returns_false_for_none(mock_app: _MockApp, monkeypatch: 
"MonkeyPatch"):
+    monkeypatch.setattr("atr.config.get", lambda: _MockConfig())
+    mock_app.extensions["admins"] = frozenset()
+    assert user.is_admin(None) is False
+
+
+def test_is_admin_returns_false_for_test_user_when_not_allowed(mock_app: 
_MockApp, monkeypatch: "MonkeyPatch"):
+    user._get_additional_admin_users.cache_clear()
+    monkeypatch.setattr("atr.config.get", lambda: 
_MockConfig(allow_tests=False))
+    mock_app.extensions["admins"] = frozenset()
+    assert user.is_admin("test") is False
+
+
+def test_is_admin_returns_false_for_unknown_user(mock_app: _MockApp, 
monkeypatch: "MonkeyPatch"):
+    monkeypatch.setattr("atr.config.get", lambda: _MockConfig())
+    mock_app.extensions["admins"] = frozenset({"alice", "bob"})
+    assert user.is_admin("nobody") is False
+
+
+def test_is_admin_returns_true_for_additional_admin(mock_app: _MockApp, 
monkeypatch: "MonkeyPatch"):
+    user._get_additional_admin_users.cache_clear()
+    monkeypatch.setattr("atr.config.get", lambda: 
_MockConfig(admin_users_additional="alice,bob"))
+    mock_app.extensions["admins"] = frozenset()
+    assert user.is_admin("alice") is True
+    assert user.is_admin("bob") is True
+
+
+def test_is_admin_returns_true_for_cached_admin(mock_app: _MockApp, 
monkeypatch: "MonkeyPatch"):
+    user._get_additional_admin_users.cache_clear()
+    monkeypatch.setattr("atr.config.get", lambda: _MockConfig())
+    mock_app.extensions["admins"] = frozenset({"cached_admin"})
+    assert user.is_admin("cached_admin") is True
+
+
+def test_is_admin_returns_true_for_test_user_when_allowed(mock_app: _MockApp, 
monkeypatch: "MonkeyPatch"):
+    user._get_additional_admin_users.cache_clear()
+    monkeypatch.setattr("atr.config.get", lambda: 
_MockConfig(allow_tests=True))
+    mock_app.extensions["admins"] = frozenset()
+    assert user.is_admin("test") is True


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to