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

vincbeck pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 73a4ffa3a53 Avoid a full asset table scan per request in simple auth 
manager (#72934)
73a4ffa3a53 is described below

commit 73a4ffa3a530da280600a7f7c2d066fac1c9044b
Author: Wei Lee <[email protected]>
AuthorDate: Wed Oct 7 22:28:06 2026 +0900

    Avoid a full asset table scan per request in simple auth manager (#72934)
    
    Scoping asset API responses to what a user may read (#72682) gave
    BaseAuthManager a default that loads the id, name and uri of every asset
    and then asks is_authorized_asset about each row. Simple auth manager
    grants asset access by role alone and never inspects the asset it is
    asked about, so that loop re-derives a single constant answer once per
    row, along with the name and uri it only loads to build the details it
    then ignores.
    
    The auth managers whose is_authorized_asset makes a remote call were
    given batched overrides at the time; simple auth manager, which is the
    default, was left on the generic path.
---
 .../auth/managers/simple/simple_auth_manager.py    | 30 +++++++++
 .../managers/simple/test_simple_auth_manager.py    | 74 ++++++++++++++++++++++
 .../core_api/routes/public/test_assets.py          |  8 ++-
 .../api_fastapi/core_api/routes/ui/test_assets.py  | 12 ++--
 4 files changed, 117 insertions(+), 7 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
index 8f15e734d2c..a030c9e034d 100644
--- 
a/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
+++ 
b/airflow-core/src/airflow/api_fastapi/auth/managers/simple/simple_auth_manager.py
@@ -33,6 +33,7 @@ from fastapi import FastAPI, Request
 from fastapi.responses import HTMLResponse
 from fastapi.staticfiles import StaticFiles
 from fastapi.templating import Jinja2Templates
+from sqlalchemy import select
 from termcolor import colored
 
 from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX
@@ -41,8 +42,11 @@ from 
airflow.api_fastapi.auth.managers.models.resource_details import AccessView
 from airflow.api_fastapi.auth.managers.simple.user import SimpleAuthManagerUser
 from airflow.api_fastapi.common.types import MenuItem
 from airflow.configuration import AIRFLOW_HOME, conf
+from airflow.models.asset import AssetModel
+from airflow.utils.session import NEW_SESSION, provide_session
 
 if TYPE_CHECKING:
+    from sqlalchemy.orm import Session
     from starlette.middleware import _MiddlewareFactory
 
     from airflow.api_fastapi.auth.managers.base_auth_manager import 
ResourceMethod
@@ -309,6 +313,32 @@ class 
SimpleAuthManager(BaseAuthManager[SimpleAuthManagerUser]):
             user=user,
         )
 
+    @provide_session
+    def get_authorized_assets(
+        self,
+        *,
+        user: SimpleAuthManagerUser,
+        method: ResourceMethod = "GET",
+        session: Session = NEW_SESSION,
+    ) -> set[int]:
+        """
+        Get the ids of the assets the user has access to.
+
+        Simple auth manager authorizes assets at the role level: 
``is_authorized_asset`` ignores the
+        asset details, so one check decides the whole listing. The default 
per-asset loop would
+        re-evaluate that same decision once per row.
+
+        This relies on ``is_authorized_asset`` not reading ``details``. If it 
starts to, or a subclass
+        overrides ``filter_authorized_assets``, remove or adjust this override.
+
+        :param user: the user
+        :param method: the method to filter on
+        :param session: the session
+        """
+        if not self.is_authorized_asset(method=method, user=user):
+            return set()
+        return set(session.scalars(select(AssetModel.id)))
+
     def is_authorized_pool(
         self,
         *,
diff --git 
a/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
 
b/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
index 5cdb5248a12..1cda1cf9061 100644
--- 
a/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
+++ 
b/airflow-core/tests/unit/api_fastapi/auth/managers/simple/test_simple_auth_manager.py
@@ -24,6 +24,7 @@ from urllib.parse import urlencode
 import pytest
 
 from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX
+from airflow.api_fastapi.auth.managers.base_auth_manager import BaseAuthManager
 from airflow.api_fastapi.auth.managers.models.resource_details import (
     AccessView,
     ConnectionDetails,
@@ -35,8 +36,20 @@ from 
airflow.api_fastapi.auth.managers.models.resource_details import (
 from airflow.api_fastapi.auth.managers.simple.simple_auth_manager import 
SimpleAuthManager
 from airflow.api_fastapi.auth.managers.simple.user import SimpleAuthManagerUser
 from airflow.api_fastapi.common.types import MenuItem
+from airflow.models.asset import AssetActive, AssetModel
 
 from tests_common.test_utils.config import conf_vars
+from tests_common.test_utils.db import clear_db_assets
+
+ASSET_AUTHORIZATION_MATRIX = [
+    pytest.param("ADMIN", "GET", True, id="admin-get"),
+    pytest.param(None, "GET", False, id="no-role-get"),
+    pytest.param("VIEWER", "GET", True, id="viewer-get"),
+    pytest.param("VIEWER", "POST", False, id="viewer-post"),
+    pytest.param("USER", "GET", True, id="user-get"),
+    pytest.param("USER", "POST", False, id="user-post"),
+    pytest.param("OP", "POST", True, id="op-post"),
+]
 
 
 class TestSimpleAuthManager:
@@ -524,3 +537,64 @@ class TestSimpleAuthManager:
             password = SimpleAuthManager._generate_password()
             assert len(password) == 16
             assert set(password).issubset(alphabet)
+
+    @pytest.fixture
+    def clean_assets(self):
+        clear_db_assets()
+        yield
+        clear_db_assets()
+
+    @pytest.mark.db_test
+    @pytest.mark.parametrize(("role", "method", "expect_authorized"), 
ASSET_AUTHORIZATION_MATRIX)
+    def test_get_authorized_assets(
+        self, auth_manager, session, clean_assets, role, method, 
expect_authorized
+    ):
+        """Three assets in the table; one role-level check, not one per asset, 
is what the override does."""
+        assets = [AssetModel(name=f"asset{i}", uri=f"s3://bucket/asset{i}", 
group="asset") for i in range(3)]
+        session.add_all(assets)
+        # The third asset has no AssetActive row and must still be returned.
+        session.add_all(AssetActive.for_asset(asset) for asset in assets[:2])
+        session.commit()
+        expected_ids = {asset.id for asset in assets} if expect_authorized 
else set()
+
+        user = SimpleAuthManagerUser(username="test", role=role)
+        with mock.patch.object(
+            auth_manager,
+            "is_authorized_asset",
+            wraps=auth_manager.is_authorized_asset,
+        ) as mock_is_authorized_asset:
+            result = auth_manager.get_authorized_assets(user=user, 
method=method, session=session)
+
+        assert result == expected_ids
+        mock_is_authorized_asset.assert_called_once_with(method=method, 
user=user)
+
+    @pytest.mark.db_test
+    def test_get_authorized_assets_empty_table(self, auth_manager, session, 
clean_assets):
+        user = SimpleAuthManagerUser(username="test", role="ADMIN")
+        with mock.patch.object(
+            auth_manager,
+            "is_authorized_asset",
+            wraps=auth_manager.is_authorized_asset,
+        ) as mock_is_authorized_asset:
+            result = auth_manager.get_authorized_assets(user=user, 
session=session)
+
+        assert result == set()
+        mock_is_authorized_asset.assert_called_once_with(method="GET", 
user=user)
+
+    @pytest.mark.db_test
+    @pytest.mark.parametrize(("role", "method", "expect_authorized"), 
ASSET_AUTHORIZATION_MATRIX)
+    def test_get_authorized_assets_matches_base_implementation(
+        self, auth_manager, session, clean_assets, role, method, 
expect_authorized
+    ):
+        assets = [AssetModel(name=f"asset{i}", uri=f"s3://bucket/asset{i}", 
group="asset") for i in range(3)]
+        session.add_all(assets)
+        session.add_all(AssetActive.for_asset(asset) for asset in assets[:2])
+        session.commit()
+
+        user = SimpleAuthManagerUser(username="test", role=role)
+        result = auth_manager.get_authorized_assets(user=user, method=method, 
session=session)
+
+        assert result == BaseAuthManager.get_authorized_assets(
+            auth_manager, user=user, method=method, session=session
+        )
+        assert bool(result) is expect_authorized
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
index a51a0ef1ae5..fb84a18d9c7 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_assets.py
@@ -418,7 +418,9 @@ class TestGetAssets(TestAssets):
             "total_entries": 2,
         }
 
-    
@mock.patch("airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_assets")
+    @mock.patch(
+        
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.get_authorized_assets"
+    )
     def test_should_return_only_assets_the_caller_may_read(
         self, mock_get_authorized_assets, test_client, session
     ):
@@ -1018,7 +1020,9 @@ class TestGetAssetEventsPerDagScoping(TestAssets):
         # The count must be scoped too, so the existence of hidden events does 
not leak.
         assert body["total_entries"] == len(expected_ids)
 
-    
@mock.patch("airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_assets")
+    @mock.patch(
+        
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.get_authorized_assets"
+    )
     def test_endpoint_returns_only_events_of_assets_the_caller_may_read(
         self, mock_get_authorized_assets, test_client, session
     ):
diff --git 
a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py 
b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
index e26dc173651..87f83aac375 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/ui/test_assets.py
@@ -124,7 +124,7 @@ class TestNextRunAssets:
         }
 
     @mock.patch(
-        
"airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_assets",
+        
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.get_authorized_assets",
         autospec=True,
     )
     def test_asset_expression_hides_assets_the_caller_may_not_read(
@@ -165,7 +165,7 @@ class TestNextRunAssets:
         assert "s3://bucket/hidden" not in redacted
 
     @mock.patch(
-        
"airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_assets",
+        
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.get_authorized_assets",
         autospec=True,
     )
     def test_should_return_only_assets_the_caller_may_read(
@@ -191,7 +191,7 @@ class TestNextRunAssets:
         assert response.json()["scheduling_asset_count"] == 2
 
     @mock.patch(
-        
"airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_assets",
+        
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.get_authorized_assets",
         autospec=True,
     )
     def test_scheduling_asset_count_is_unaffected_when_no_asset_is_readable(
@@ -216,7 +216,7 @@ class TestNextRunAssets:
         assert response.json()["scheduling_asset_count"] == 2
 
     @mock.patch(
-        
"airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_assets",
+        
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.get_authorized_assets",
         autospec=True,
     )
     def 
test_partitioned_dag_should_hide_keys_of_assets_the_caller_may_not_read(
@@ -686,7 +686,9 @@ class TestGetAssetsUi:
         assert body["total_entries"] == 1
         assert body["assets"][0]["name"] == "ui_asset"
 
-    
@mock.patch("airflow.api_fastapi.auth.managers.base_auth_manager.BaseAuthManager.get_authorized_assets")
+    @mock.patch(
+        
"airflow.api_fastapi.auth.managers.simple.simple_auth_manager.SimpleAuthManager.get_authorized_assets"
+    )
     def test_should_return_only_assets_the_caller_may_read(
         self, mock_get_authorized_assets, test_client, session
     ):

Reply via email to