This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/sbp by this push:
     new 0580960  Use a strict model for Quart cookie session data
0580960 is described below

commit 05809602f4fd353b19f5ac3819258a03bf22898e
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Feb 2 20:12:02 2026 +0000

    Use a strict model for Quart cookie session data
---
 atr/admin/__init__.py                  |  18 +++++-
 atr/get/test.py                        |  25 +++++----
 atr/models/__init__.py                 |   4 +-
 atr/models/{__init__.py => session.py} |  20 ++++++-
 atr/server.py                          |   4 +-
 atr/util.py                            | 100 +++++++++++++++++++++++++++++++++
 pyproject.toml                         |   3 +-
 7 files changed, 153 insertions(+), 21 deletions(-)

diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index fc4bafa..5811edd 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -49,6 +49,7 @@ import atr.htm as htm
 import atr.ldap as ldap
 import atr.log as log
 import atr.mapping as mapping
+import atr.models.session
 import atr.models.sql as sql
 import atr.principal as principal
 import atr.storage as storage
@@ -164,7 +165,22 @@ async def browse_as_post(session: web.Committer, 
browse_form: BrowseAsUserForm)
     )
     log.info(f"New Quart cookie (not ASFQuart session) data: {log_safe_data}")
     asfquart.session.clear()
-    asfquart.session.write(new_session_data)
+    # TODO: Make this safer
+    session_cookie = atr.models.session.CookieData(
+        uid=new_session_data["uid"],
+        dn=new_session_data.get("dn"),
+        fullname=new_session_data.get("fullname"),
+        email=new_session_data.get("email"),
+        isMember=new_session_data.get("isMember", False),
+        isChair=new_session_data.get("isChair", False),
+        isRoot=new_session_data.get("isRoot", False),
+        pmcs=new_session_data.get("pmcs", []),
+        projects=new_session_data.get("projects", []),
+        mfa=new_session_data.get("mfa", False),
+        roleaccount=new_session_data.get("roleaccount", 
new_session_data.get("isRole", False)),
+        metadata=new_session_data.get("metadata", {}),
+    )
+    util.write_quart_session_cookie(session_cookie)
 
     await quart.flash(
         f"You are now browsing as '{new_uid}'. To return to your own account, 
please log out and log back in.",
diff --git a/atr/get/test.py b/atr/get/test.py
index adf26cb..a76f623 100644
--- a/atr/get/test.py
+++ b/atr/get/test.py
@@ -16,7 +16,6 @@
 # under the License.
 
 import asfquart.base as base
-import asfquart.session
 
 import atr.blueprints.get as get
 import atr.config as config
@@ -24,9 +23,11 @@ import atr.form as form
 import atr.get.root as root
 import atr.get.vote as vote
 import atr.htm as htm
+import atr.models.session
 import atr.models.sql as sql
 import atr.shared as shared
 import atr.template as template
+import atr.util as util
 import atr.web as web
 
 
@@ -52,18 +53,18 @@ async def test_login(session: web.Committer | None) -> 
web.WerkzeugResponse:
     if not config.get().ALLOW_TESTS:
         raise base.ASFQuartException("Test login not enabled", errorcode=404)
 
-    session_data = {
-        "uid": "test",
-        "fullname": "Test User",
-        "committees": ["test"],
-        "projects": ["test"],
-        "isMember": False,
-        "isChair": False,
-        "isRole": False,
-        "metadata": {},
-    }
+    session_data = atr.models.session.CookieData(
+        uid="test",
+        fullname="Test User",
+        pmcs=["test"],
+        projects=["test"],
+        isMember=False,
+        isChair=False,
+        roleaccount=False,
+        metadata={},
+    )
 
-    asfquart.session.write(session_data)
+    util.write_quart_session_cookie(session_data)
     return await web.redirect(root.index)
 
 
diff --git a/atr/models/__init__.py b/atr/models/__init__.py
index daca400..3d7d9fd 100644
--- a/atr/models/__init__.py
+++ b/atr/models/__init__.py
@@ -15,7 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from . import api, basic, distribution, helpers, results, schema, sql, 
tabulate, validation
+from . import api, basic, distribution, helpers, results, schema, session, 
sql, tabulate, validation
 
 # If we use .__name__, pyright gives a warning
-__all__ = ["api", "basic", "distribution", "helpers", "results", "schema", 
"sql", "tabulate", "validation"]
+__all__ = ["api", "basic", "distribution", "helpers", "results", "schema", 
"session", "sql", "tabulate", "validation"]
diff --git a/atr/models/__init__.py b/atr/models/session.py
similarity index 63%
copy from atr/models/__init__.py
copy to atr/models/session.py
index daca400..213d30c 100644
--- a/atr/models/__init__.py
+++ b/atr/models/session.py
@@ -15,7 +15,21 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from . import api, basic, distribution, helpers, results, schema, sql, 
tabulate, validation
+from typing import Any
 
-# If we use .__name__, pyright gives a warning
-__all__ = ["api", "basic", "distribution", "helpers", "results", "schema", 
"sql", "tabulate", "validation"]
+from . import schema
+
+
+class CookieData(schema.Strict):
+    uid: str
+    dn: str | None = None
+    fullname: str | None = None
+    email: str | None = None
+    isMember: bool = False
+    isChair: bool = False
+    isRoot: bool = False
+    pmcs: list[str] = schema.factory(list)
+    projects: list[str] = schema.factory(list)
+    mfa: bool = False
+    roleaccount: bool = False
+    metadata: dict[str, Any] = schema.factory(dict)
diff --git a/atr/server.py b/atr/server.py
index 4ad9c66..d889e61 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -435,7 +435,9 @@ def _app_setup_request_lifecycle(app: base.QuartApp) -> 
None:
         if created_at_str is None:
             # First time seeing this session, record creation time
             session.metadata["created_at"] = 
datetime.datetime.now(datetime.UTC).isoformat()
-            asfquart.session.write(session)
+            pmcs = util.cookie_pmcs_or_session_pmcs(session)
+            session_data = util.session_cookie_data_from_client(session, pmcs)
+            util.write_quart_session_cookie(session_data)
             return
 
         # Parse the creation timestamp and check session age
diff --git a/atr/util.py b/atr/util.py
index b2d67f1..f573995 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -49,6 +49,7 @@ import quart
 import atr.config as config
 import atr.ldap as ldap
 import atr.log as log
+import atr.models.session
 import atr.models.sql as sql
 import atr.models.validation as validation
 import atr.registry as registry
@@ -74,6 +75,9 @@ NPM_PACKAGE_JSON_MAX_SIZE: Final[int] = 512 * 1024
 USER_TESTS_ADDRESS: Final[str] = "[email protected]"
 
 
+NoneType: Final[type[None]] = type(None)
+
+
 @dataclasses.dataclass(frozen=True)
 class NpmPackInfo:
     name: str
@@ -257,6 +261,29 @@ async def content_list(
         )
 
 
+def cookie_pmcs() -> list[str] | None:
+    pmcs = None
+    try:
+        cookie_id = asfquart.APP.app_id
+        cookie_session = quart.session.get(cookie_id, {})
+        if isinstance(cookie_session, dict):
+            pmcs = cookie_session.get("pmcs")
+            if not isinstance(pmcs, list):
+                pmcs = None
+    except Exception:
+        pmcs = None
+    return pmcs
+
+
+def cookie_pmcs_or_session_pmcs(session_data: session.ClientSession) -> 
list[str]:
+    pmcs = cookie_pmcs()
+    if pmcs is None:
+        pmcs = session_data.committees
+    if not isinstance(pmcs, list) or (not (all(isinstance(item, str) for item 
in pmcs))):
+        raise TypeError("Session pmcs must be a list[str]")
+    return pmcs
+
+
 async def create_hard_link_clone(
     source_dir: pathlib.Path,
     dest_dir: pathlib.Path,
@@ -918,6 +945,75 @@ async def session_cache_write(cache_data: dict[str, dict]) 
-> None:
     await atomic_write_file(cache_path, json.dumps(cache_data, indent=2))
 
 
+def session_cookie_data_from_client(  # noqa: C901
+    session_data: session.ClientSession, pmcs: list[str]
+) -> atr.models.session.CookieData:
+    uid = session_data.uid
+    if not isinstance(uid, str):
+        raise TypeError("Session uid must be a str")
+
+    dn = session_data.dn
+    if (dn is not None) and (not isinstance(dn, str)):
+        raise TypeError("Session dn must be a str or None")
+
+    fullname = session_data.fullname
+    if (fullname is not None) and (not isinstance(fullname, str)):
+        raise TypeError("Session fullname must be a str or None")
+
+    email = session_data.email
+    # The type checker doesn't believe that session_data.email can be None
+    # But we get the data from upstream, so we can't be entirely sure about 
this
+    # Therefore, just in case it can be, we use the following convoluted 
approach
+    if (not isinstance(email, NoneType)) and (not isinstance(email, str)):
+        raise TypeError("Session email must be a str or None")
+
+    is_member = session_data.isMember
+    if not isinstance(is_member, bool):
+        raise TypeError("Session isMember must be a bool")
+
+    is_chair = session_data.isChair
+    if not isinstance(is_chair, bool):
+        raise TypeError("Session isChair must be a bool")
+
+    is_root = session_data.isRoot
+    if not isinstance(is_root, bool):
+        raise TypeError("Session isRoot must be a bool")
+
+    if (not isinstance(pmcs, list)) or (not (all(isinstance(item, str) for 
item in pmcs))):
+        raise TypeError("Session pmcs must be a list[str]")
+
+    projects = session_data.projects
+    if (not isinstance(projects, list)) or (not (all(isinstance(item, str) for 
item in projects))):
+        raise TypeError("Session projects must be a list[str]")
+
+    mfa = session_data.mfa
+    if not isinstance(mfa, bool):
+        raise TypeError("Session mfa must be a bool")
+
+    roleaccount = session_data.isRole
+    if not isinstance(roleaccount, bool):
+        raise TypeError("Session roleaccount must be a bool")
+
+    metadata = session_data.metadata
+    if not isinstance(metadata, dict):
+        raise TypeError("Session metadata must be a dict")
+
+    return atr.models.session.CookieData(
+        uid=uid,
+        dn=dn,
+        fullname=fullname,
+        email=email,
+        isMember=is_member,
+        isChair=is_chair,
+        isRoot=is_root,
+        pmcs=pmcs,
+        projects=projects,
+        mfa=mfa,
+        roleaccount=roleaccount,
+        metadata=metadata,
+    )
+
+
 def static_path(*args: str) -> str:
     filename = str(pathlib.PurePosixPath(*args))
     return quart.url_for("static", filename=filename)
@@ -1138,6 +1234,10 @@ def version_sort_key(version: str) -> bytes:
     return bytes(result)
 
 
+def write_quart_session_cookie(session_data: atr.models.session.CookieData) -> 
None:
+    session.write(session_data.model_dump(mode="json"))
+
+
 async def _create_hard_link_clone_checks(
     source_dir: pathlib.Path,
     dest_dir: pathlib.Path,
diff --git a/pyproject.toml b/pyproject.toml
index 7705d8b..3148339 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -156,8 +156,7 @@ select = [
 [tool.ruff.lint.per-file-ignores]
 "atr/analysis.py" = ["RUF001"]
 "atr/db/__init__.py" = ["C901"]
-"atr/models/cyclonedx/__init__.py" = ["N815"]
-"atr/models/cyclonedx/spdx.py" = ["N815"]
+"atr/models/session.py" = ["N815"]
 "atr/sbom/conformance.py" = ["C901"]
 "atr/sbom/licenses.py" = ["C901"]
 "migrations/env.py" = ["E402"]


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

Reply via email to