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

EnxDev pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 3f8a23622c7 perf(security): memoise the user subject lookup within a 
request (#44017)
3f8a23622c7 is described below

commit 3f8a23622c76e5423fbec7746102e6b03614da42
Author: shaurya <[email protected]>
AuthorDate: Wed Sep 30 19:48:14 2026 +0530

    perf(security): memoise the user subject lookup within a request (#44017)
    
    Co-authored-by: Shaurya <[email protected]>
    Co-authored-by: Evan Rusackas <[email protected]>
    Co-authored-by: Evan Rusackas <[email protected]>
    Co-authored-by: Claude Sonnet 5 <[email protected]>
---
 superset/subjects/utils.py              | 40 ++++++++++++++++-
 tests/unit_tests/subjects/test_utils.py | 80 +++++++++++++++++++++++++++++++++
 2 files changed, 119 insertions(+), 1 deletion(-)

diff --git a/superset/subjects/utils.py b/superset/subjects/utils.py
index b6cccc8150c..c09fa04449c 100644
--- a/superset/subjects/utils.py
+++ b/superset/subjects/utils.py
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
     from flask_appbuilder.security.sqla.models import Group, Role, User
     from sqlalchemy.sql import CompoundSelect, Select
 
-from flask import has_app_context
+from flask import has_app_context, has_request_context, request
 from sqlalchemy import select, union_all
 
 from superset import db
@@ -206,7 +206,45 @@ def get_user_subject_ids(user_id: int) -> list[int]:
     1. The user's own USER-type subject
     2. ROLE-type subjects for all direct and group-derived roles the user has
     3. GROUP-type subjects for all groups the user belongs to
+
+    Memoised for the duration of the request, keyed by user id. Authorization
+    calls this once per object checked -- ``is_editor``/``is_viewer`` run it 
for
+    every chart on a dashboard. The cache can miss a subject created earlier in
+    the same request (the create paths in ``commands/utils.py`` read back 
through
+    here), but that never grants access to anyone else, in either direction. A
+    cache that misses a just-created subject: the subject is not yet listed in
+    any resource's editors or viewers, so an ``is_editor``/``is_viewer`` check
+    still returns the same answer. A cache that retains a subject removed
+    earlier in the same request: membership only changes in the Admin user,
+    role and group endpoints or in login-time role sync, and none of those
+    requests read this back afterwards. Neither direction flips a decision
+    for a third party, and the staleness window is bounded by the request.
+
+    The cache lives on ``flask.request``, not ``g``: ``g`` is bound to the app
+    context, which a worker can hold open across many requests, so a 
``g``-keyed
+    cache would survive past the request it was built for. The request object 
is
+    torn down when the request ends, giving the cache exactly the request
+    lifetime the docstring promises.
+
+    Guest users don't reach this cache: ``is_viewer`` falls through to
+    ``subjects_from_roles`` for them, still paying a query per role per
+    resource. That path is out of scope here.
     """
+    if not has_request_context():
+        return _query_user_subject_ids(user_id)
+    req = request._get_current_object()
+    cache: dict[int, list[int]] | None = getattr(req, "_user_subject_ids", 
None)
+    if cache is None:
+        cache = {}
+        req._user_subject_ids = cache
+    if user_id not in cache:
+        cache[user_id] = _query_user_subject_ids(user_id)
+    # Copy: callers hand this list on, and one of them puts it in the bootstrap
+    # payload that COMMON_BOOTSTRAP_OVERRIDES_FUNC can edit.
+    return list(cache[user_id])
+
+
+def _query_user_subject_ids(user_id: int) -> list[int]:
     result = db.session.execute(get_user_subject_ids_subquery(user_id))
     return [row[0] for row in result]
 
diff --git a/tests/unit_tests/subjects/test_utils.py 
b/tests/unit_tests/subjects/test_utils.py
index e5ebf4323e5..722a26d5ead 100644
--- a/tests/unit_tests/subjects/test_utils.py
+++ b/tests/unit_tests/subjects/test_utils.py
@@ -34,6 +34,7 @@ from superset.subjects.utils import (
     get_or_create_group_subject,
     get_or_create_role_subject,
     get_user_group_subjects,
+    get_user_subject_ids,
     get_user_subject_ids_subquery,
 )
 
@@ -611,3 +612,82 @@ def test_compute_subjects_all_variants(mock_compute):
         ensure_no_lockout=True,
         field_name="editors",
     )
+
+
+def test_get_user_subject_ids_memoises_within_a_request(app) -> None:
+    """The subject lookup runs once per user per request, not once per 
check."""
+    with patch(
+        "superset.subjects.utils._query_user_subject_ids", return_value=[7, 8]
+    ) as query:
+        with app.test_request_context("/"):
+            assert get_user_subject_ids(1) == [7, 8]
+            assert get_user_subject_ids(1) == [7, 8]
+            assert query.call_count == 1
+
+            # A different principal is a different cache entry.
+            get_user_subject_ids(2)
+            assert query.call_count == 2
+
+
+def test_get_user_subject_ids_cache_does_not_leak_across_requests(app) -> None:
+    """The cache is tied to the request, not the enclosing app context.
+
+    A worker can hold one app context open across many requests. If the cache
+    lived on ``g`` it would survive from one request into the next and serve a
+    stale answer; tied to the request object it starts empty each time.
+    """
+    with app.app_context():
+        with patch(
+            "superset.subjects.utils._query_user_subject_ids", 
return_value=[7, 8]
+        ) as query:
+            with app.test_request_context("/"):
+                assert get_user_subject_ids(1) == [7, 8]
+                assert get_user_subject_ids(1) == [7, 8]
+                assert query.call_count == 1
+
+        # Same app context, a new request. The membership changed underneath;
+        # the second request must re-query rather than reuse the first's cache.
+        with patch(
+            "superset.subjects.utils._query_user_subject_ids", return_value=[9]
+        ) as query:
+            with app.test_request_context("/"):
+                assert get_user_subject_ids(1) == [9]
+                assert query.call_count == 1
+
+
+def test_get_user_subject_ids_not_cached_outside_a_request(app_context) -> 
None:
+    """Background tasks and CLI commands keep the uncached behaviour."""
+    with patch(
+        "superset.subjects.utils._query_user_subject_ids", return_value=[7]
+    ) as query:
+        get_user_subject_ids(1)
+        get_user_subject_ids(1)
+        assert query.call_count == 2
+
+
+def test_get_user_subject_ids_returns_a_copy(app) -> None:
+    """Callers hand this list on, so mutating it must not poison the cache."""
+    with patch("superset.subjects.utils._query_user_subject_ids", 
return_value=[7]):
+        with app.test_request_context("/"):
+            first = get_user_subject_ids(1)
+            first.append(999)
+            assert get_user_subject_ids(1) == [7]
+
+
+def test_get_user_subject_ids_serves_a_stale_set_within_the_request(app) -> 
None:
+    """A subject added mid-request is not seen until the next request.
+
+    This is the staleness window the docstring relies on: a create path can add
+    a subject after the cache is warm, and this call keeps returning the set it
+    first saw. It is safe because a subject created mid-request is not yet 
listed
+    in any resource's editors or viewers, so a check that omits it returns the
+    same access decision.
+    """
+    with app.test_request_context("/"):
+        with patch("superset.subjects.utils._query_user_subject_ids", 
return_value=[7]):
+            assert get_user_subject_ids(1) == [7]
+        # Membership changed underneath, same request: the cache still answers 
7.
+        with patch(
+            "superset.subjects.utils._query_user_subject_ids", 
return_value=[7, 99]
+        ):
+            assert get_user_subject_ids(1) == [7]

Reply via email to