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 bad1c58  Allow users to cache their session data in debug mode
bad1c58 is described below

commit bad1c58996f87e470bdc72438064abb2850859f3
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Oct 24 16:01:46 2025 +0100

    Allow users to cache their session data in debug mode
---
 atr/principal.py       |  15 +++++
 atr/routes/__init__.py |   2 +
 atr/routes/user.py     | 152 +++++++++++++++++++++++++++++++++++++++++++++++++
 atr/ssh.py             |   1 +
 atr/util.py            |  36 ++++++++++++
 5 files changed, 206 insertions(+)

diff --git a/atr/principal.py b/atr/principal.py
index dd70885..72cc894 100644
--- a/atr/principal.py
+++ b/atr/principal.py
@@ -28,6 +28,7 @@ import atr.config as config
 import atr.ldap as ldap
 import atr.log as log
 import atr.route as route
+import atr.util as util
 
 LDAP_CHAIRS_BASE = "cn=pmc-chairs,ou=groups,ou=services,dc=apache,dc=org"
 LDAP_DN = "uid=%s,ou=people,dc=apache,dc=org"
@@ -305,6 +306,20 @@ class AuthoriserLDAP:
             self.__cache.last_refreshed[asf_uid] = int(time.time())
             return
 
+        if config.get_mode() == config.Mode.Debug:
+            session_cache = await util.session_cache_read()
+            if asf_uid in session_cache:
+                cached_session = session_cache[asf_uid]
+                committees = frozenset(cached_session.get("pmcs", []))
+                projects = frozenset(cached_session.get("projects", []))
+                committees, projects = _augment_test_membership(committees, 
projects)
+
+                self.__cache.member_of[asf_uid] = committees
+                self.__cache.participant_of[asf_uid] = projects
+                self.__cache.last_refreshed[asf_uid] = int(time.time())
+                log.info(f"Loaded session data for {asf_uid} from session 
cache file")
+                return
+
         try:
             c = Committer(asf_uid)
             await asyncio.to_thread(c.verify)
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index b6e8ab3..0d64cae 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -40,6 +40,7 @@ import atr.routes.sbom as sbom
 import atr.routes.start as start
 import atr.routes.tokens as tokens
 import atr.routes.upload as upload
+import atr.routes.user as user
 import atr.routes.vote as vote
 import atr.routes.voting as voting
 
@@ -69,6 +70,7 @@ __all__ = [
     "start",
     "tokens",
     "upload",
+    "user",
     "vote",
     "voting",
 ]
diff --git a/atr/routes/user.py b/atr/routes/user.py
new file mode 100644
index 0000000..7ae4a47
--- /dev/null
+++ b/atr/routes/user.py
@@ -0,0 +1,152 @@
+# 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 __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import htpy
+import quart
+
+import atr.forms as forms
+import atr.htm as htm
+import atr.route as route
+import atr.template as template
+import atr.util as util
+
+if TYPE_CHECKING:
+    import werkzeug.wrappers.response as response
+
+
+class CacheForm(forms.Typed):
+    cache_submit = forms.submit("Cache me!")
+
+
+class DeleteCacheForm(forms.Typed):
+    delete_submit = forms.submit("Delete my cache")
+
+
[email protected]("/user/cache", methods=["GET"])
+async def cache_get(session: route.CommitterSession) -> str:
+    cache_form = await CacheForm.create_form()
+    delete_cache_form = await DeleteCacheForm.create_form()
+
+    cache_data = await util.session_cache_read()
+    user_cached = session.uid in cache_data
+
+    block = htm.Block()
+
+    block.h1["Session cache management"]
+
+    block.p[
+        """This page allows you to cache your ASFQuart session information for 
use in
+        contexts where web authentication is not available, such as SSH and 
rsync, the
+        API, and background tasks. This is intended for developers only."""
+    ]
+
+    if user_cached:
+        cached_entry = cache_data[session.uid]
+        block.h2["Your cached session"]
+        block.p["Your session is currently cached."]
+
+        tbody = htm.Block(htpy.tbody)
+        tbody.append(htpy.tr[htpy.th["User ID"], htpy.td[session.uid]])
+        if "fullname" in cached_entry:
+            tbody.append(htpy.tr[htpy.th["Full name"], 
htpy.td[cached_entry["fullname"]]])
+        if "email" in cached_entry:
+            tbody.append(htpy.tr[htpy.th["Email"], 
htpy.td[cached_entry["email"]]])
+        if "pmcs" in cached_entry:
+            committees = ", ".join(cached_entry["pmcs"]) if 
cached_entry["pmcs"] else "-"
+            tbody.append(htpy.tr[htpy.th["Committees"], htpy.td[committees]])
+        if "projects" in cached_entry:
+            projects = ", ".join(cached_entry["projects"]) if 
cached_entry["projects"] else "-"
+            tbody.append(htpy.tr[htpy.th["Projects"], htpy.td[projects]])
+
+        block.table(".table.table-striped.table-bordered")[tbody.collect()]
+
+        block.h3["Delete cache"]
+        block.p["Remove your cached session information:"]
+        delete_form_element = forms.render_simple(
+            delete_cache_form,
+            action=quart.request.path,
+            submit_classes="btn-danger",
+        )
+        block.append(delete_form_element)
+    else:
+        block.h2["No cached session"]
+        block.p["Your session is not currently cached."]
+
+        block.h3["Cache current session"]
+        block.p["Press the button below to cache your current session 
information:"]
+        cache_form_element = forms.render_simple(
+            cache_form,
+            action=quart.request.path,
+            submit_classes="btn-primary",
+        )
+        block.append(cache_form_element)
+
+    return await template.blank("Session cache management", 
content=block.collect())
+
+
[email protected]("/user/cache", methods=["POST"])
+async def session_post(session: route.CommitterSession) -> response.Response:
+    form_data = await quart.request.form
+
+    cache_form = await CacheForm.create_form(data=form_data)
+    delete_cache_form = await DeleteCacheForm.create_form(data=form_data)
+
+    if cache_form.cache_submit.data:
+        await _cache_session(session)
+        await quart.flash("Your session has been cached successfully", 
"success")
+    elif delete_cache_form.delete_submit.data:
+        await _delete_session_cache(session)
+        await quart.flash("Your cached session has been deleted", "success")
+    else:
+        await quart.flash("Invalid form submission", "error")
+
+    return await session.redirect(cache_get)
+
+
+async def _cache_session(session: route.CommitterSession) -> None:
+    cache_data = await util.session_cache_read()
+
+    session_data = {
+        "uid": session.uid,
+        "dn": getattr(session, "dn", None),
+        "fullname": getattr(session, "fullname", None),
+        "email": getattr(session, "email", f"{session.uid}@apache.org"),
+        "isMember": getattr(session, "isMember", False),
+        "isChair": getattr(session, "isChair", False),
+        "isRoot": getattr(session, "isRoot", False),
+        "pmcs": getattr(session, "committees", []),
+        "projects": getattr(session, "projects", []),
+        "mfa": getattr(session, "mfa", False),
+        "roleaccount": getattr(session, "isRole", False),
+        "metadata": getattr(session, "metadata", {}),
+    }
+
+    cache_data[session.uid] = session_data
+
+    await util.session_cache_write(cache_data)
+
+
+async def _delete_session_cache(session: route.CommitterSession) -> None:
+    cache_data = await util.session_cache_read()
+
+    if session.uid in cache_data:
+        del cache_data[session.uid]
+        await util.session_cache_write(cache_data)
diff --git a/atr/ssh.py b/atr/ssh.py
index 862d8b5..6e04cb9 100644
--- a/atr/ssh.py
+++ b/atr/ssh.py
@@ -353,6 +353,7 @@ async def _step_06a_validate_read_permissions(
         sql.ReleasePhase.RELEASE_CANDIDATE,
         sql.ReleasePhase.RELEASE_PREVIEW,
     }
+    print(release)
     if release.phase not in allowed_read_phases:
         raise RsyncArgsError(f"Release '{release.name}' is not in a readable 
phase ({release.phase.value})")
 
diff --git a/atr/util.py b/atr/util.py
index 3909aba..c0b5208 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -774,6 +774,42 @@ def release_directory_version(release: sql.Release) -> 
pathlib.Path:
     return path
 
 
+async def session_cache_read() -> dict[str, dict]:
+    cache_path = pathlib.Path(config.get().STATE_DIR) / 
"user_session_cache.json"
+    try:
+        async with aiofiles.open(cache_path) as f:
+            content = await f.read()
+            return json.loads(content)
+    except FileNotFoundError:
+        return {}
+    except json.JSONDecodeError:
+        return {}
+
+
+async def session_cache_write(cache_data: dict[str, dict]) -> None:
+    cache_path = pathlib.Path(config.get().STATE_DIR) / 
"user_session_cache.json"
+
+    cache_dir = cache_path.parent
+    await asyncio.to_thread(os.makedirs, cache_dir, exist_ok=True)
+
+    # Use the same pattern as update_atomic_symlink for the temporary file name
+    temp_path = cache_dir / f".{cache_path.name}.{uuid.uuid4()}.tmp"
+
+    try:
+        async with aiofiles.open(temp_path, "w") as f:
+            await f.write(json.dumps(cache_data, indent=2))
+            await f.flush()
+            await asyncio.to_thread(os.fsync, f.fileno())
+
+        await aiofiles.os.rename(temp_path, cache_path)
+    except Exception:
+        try:
+            await aiofiles.os.remove(temp_path)
+        except FileNotFoundError:
+            pass
+        raise
+
+
 def static_path(*args: str) -> str:
     filename = str(pathlib.PurePosixPath(*args))
     return quart.url_for("static", filename=filename)


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

Reply via email to