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 7b8c6ffbbc7 Add order_by parameter to GET /permissions for pagination 
consistency (#63418)
7b8c6ffbbc7 is described below

commit 7b8c6ffbbc7741ad2c063355888ee6ba6475377f
Author: Henry Chen <[email protected]>
AuthorDate: Thu Mar 12 23:31:34 2026 +0800

    Add order_by parameter to GET /permissions for pagination consistency 
(#63418)
    
    GET /permissions lacked the order_by parameter present on other list
    endpoints (e.g. GET /roles), causing non-deterministic pagination.
---
 .../openapi/v2-fab-auth-manager-generated.yaml     | 15 +++++
 .../fab/auth_manager/api_fastapi/routes/roles.py   |  6 +-
 .../fab/auth_manager/api_fastapi/services/roles.py | 11 +++-
 .../auth_manager/api_fastapi/routes/test_roles.py  | 71 +++++++++++++++++++++-
 .../api_fastapi/services/test_roles.py             | 46 +++++++++++++-
 5 files changed, 142 insertions(+), 7 deletions(-)

diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/openapi/v2-fab-auth-manager-generated.yaml
 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/openapi/v2-fab-auth-manager-generated.yaml
index 9ad5472e709..5d81b0b416f 100644
--- 
a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/openapi/v2-fab-auth-manager-generated.yaml
+++ 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/openapi/v2-fab-auth-manager-generated.yaml
@@ -409,6 +409,15 @@ paths:
       - OAuth2PasswordBearer: []
       - HTTPBearer: []
       parameters:
+      - name: order_by
+        in: query
+        required: false
+        schema:
+          type: string
+          description: Field to order by. Prefix with '-' for descending.
+          default: id
+          title: Order By
+        description: Field to order by. Prefix with '-' for descending.
       - name: offset
         in: query
         required: false
@@ -434,6 +443,12 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/PermissionCollectionResponse'
+        '400':
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
+          description: Bad Request
         '401':
           content:
             application/json:
diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/roles.py
 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/roles.py
index 64668b56659..3b799905a56 100644
--- 
a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/roles.py
+++ 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/roles.py
@@ -132,6 +132,7 @@ def patch_role(
     response_model=PermissionCollectionResponse,
     responses=create_openapi_http_exception_doc(
         [
+            status.HTTP_400_BAD_REQUEST,
             status.HTTP_401_UNAUTHORIZED,
             status.HTTP_403_FORBIDDEN,
             status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -140,9 +141,10 @@ def patch_role(
     dependencies=[Depends(requires_fab_custom_view("GET", 
permissions.RESOURCE_ROLE))],
 )
 def get_permissions(
+    order_by: str = Query("id", description="Field to order by. Prefix with 
'-' for descending."),
     limit: int = Depends(get_effective_limit()),
     offset: int = Query(0, ge=0, description="Number of items to skip before 
starting to collect results."),
-):
+) -> PermissionCollectionResponse:
     """List all action-resource (permission) pairs."""
     with get_application_builder():
-        return FABAuthManagerRoles.get_permissions(limit=limit, offset=offset)
+        return FABAuthManagerRoles.get_permissions(order_by=order_by, 
limit=limit, offset=offset)
diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/roles.py
 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/roles.py
index 44f82caa95b..dda6515c265 100644
--- 
a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/roles.py
+++ 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/roles.py
@@ -164,13 +164,22 @@ class FABAuthManagerRoles:
         return RoleResponse.model_validate(update_data)
 
     @classmethod
-    def get_permissions(cls, *, limit: int, offset: int) -> 
PermissionCollectionResponse:
+    def get_permissions(cls, *, order_by: str, limit: int, offset: int) -> 
PermissionCollectionResponse:
         security_manager = get_fab_auth_manager().security_manager
         session = security_manager.session
         total_entries = 
session.scalars(select(func.count(Permission.id))).one()
+        ordering = build_ordering(
+            order_by,
+            allowed={
+                "id": Permission.id,
+                "action_id": Permission.action_id,
+                "resource_id": Permission.resource_id,
+            },
+        )
         query = (
             select(Permission)
             .options(joinedload(Permission.action), 
joinedload(Permission.resource))
+            .order_by(ordering)
             .offset(offset)
             .limit(limit)
         )
diff --git 
a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/test_roles.py 
b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/test_roles.py
index bdfdf5ac99a..a1ed2e453f7 100644
--- a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/test_roles.py
+++ b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/test_roles.py
@@ -580,7 +580,7 @@ class TestRoles:
             resp = test_client.get("/fab/v1/permissions")
             assert resp.status_code == 200
             assert resp.json() == dummy.model_dump(by_alias=True)
-            
mock_permissions.get_permissions.assert_called_once_with(limit=100, offset=0)
+            
mock_permissions.get_permissions.assert_called_once_with(order_by="id", 
limit=100, offset=0)
 
     
@patch("airflow.providers.fab.auth_manager.api_fastapi.routes.roles.FABAuthManagerRoles")
     
@patch("airflow.providers.fab.auth_manager.api_fastapi.security.get_auth_manager")
@@ -599,3 +599,72 @@ class TestRoles:
             resp = test_client.get("/fab/v1/permissions")
             assert resp.status_code == 403
             mock_permissions.get_permissions.assert_not_called()
+
+    
@patch("airflow.providers.fab.auth_manager.api_fastapi.routes.roles.FABAuthManagerRoles")
+    
@patch("airflow.providers.fab.auth_manager.api_fastapi.security.get_auth_manager")
+    @patch(
+        
"airflow.providers.fab.auth_manager.api_fastapi.routes.roles.get_application_builder",
+        return_value=_noop_cm(),
+    )
+    @patch("airflow.providers.fab.auth_manager.api_fastapi.parameters.conf")
+    def test_get_permissions_passes_params_and_clamps_limit(
+        self,
+        conf_mock,
+        mock_get_application_builder,
+        mock_get_auth_manager,
+        mock_permissions,
+        test_client,
+        as_user,
+    ):
+        conf_mock.getint.side_effect = lambda section, option: {
+            "maximum_page_limit": 50,
+            "fallback_page_limit": 20,
+        }[option]
+
+        mgr = MagicMock()
+        mgr.is_authorized_custom_view.return_value = True
+        mock_get_auth_manager.return_value = mgr
+
+        dummy = PermissionCollectionResponse(permissions=[], total_entries=0)
+        mock_permissions.get_permissions.return_value = dummy
+
+        with as_user():
+            resp = test_client.get(
+                "/fab/v1/permissions", params={"order_by": "-id", "limit": 
1000, "offset": 5}
+            )
+            assert resp.status_code == 200
+            assert resp.json() == dummy.model_dump(by_alias=True)
+            
mock_permissions.get_permissions.assert_called_once_with(order_by="-id", 
limit=50, offset=5)
+
+    
@patch("airflow.providers.fab.auth_manager.api_fastapi.routes.roles.FABAuthManagerRoles")
+    
@patch("airflow.providers.fab.auth_manager.api_fastapi.security.get_auth_manager")
+    @patch(
+        
"airflow.providers.fab.auth_manager.api_fastapi.routes.roles.get_application_builder",
+        return_value=_noop_cm(),
+    )
+    @patch("airflow.providers.fab.auth_manager.api_fastapi.parameters.conf")
+    def test_get_permissions_uses_fallback_when_limit_zero(
+        self,
+        conf_mock,
+        mock_get_application_builder,
+        mock_get_auth_manager,
+        mock_permissions,
+        test_client,
+        as_user,
+    ):
+        conf_mock.getint.side_effect = lambda section, option: {
+            "maximum_page_limit": 100,
+            "fallback_page_limit": 33,
+        }[option]
+
+        mgr = MagicMock()
+        mgr.is_authorized_custom_view.return_value = True
+        mock_get_auth_manager.return_value = mgr
+
+        dummy = PermissionCollectionResponse(permissions=[], total_entries=0)
+        mock_permissions.get_permissions.return_value = dummy
+
+        with as_user():
+            resp = test_client.get("/fab/v1/permissions", params={"limit": 0})
+            assert resp.status_code == 200
+            
mock_permissions.get_permissions.assert_called_once_with(order_by="id", 
limit=33, offset=0)
diff --git 
a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/test_roles.py 
b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/test_roles.py
index 1dec2954b60..88620b80b23 100644
--- 
a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/test_roles.py
+++ 
b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/test_roles.py
@@ -384,7 +384,7 @@ class TestRolesService:
         fab_auth_manager.security_manager = MagicMock(session=session)
         get_fab_auth_manager.return_value = fab_auth_manager
 
-        out = FABAuthManagerRoles.get_permissions(limit=10, offset=0)
+        out = FABAuthManagerRoles.get_permissions(order_by="id", limit=10, 
offset=0)
         assert isinstance(out, PermissionCollectionResponse)
         assert out.total_entries == 1
         assert len(out.permissions) == 1
@@ -402,7 +402,7 @@ class TestRolesService:
         fab_auth_manager.security_manager = MagicMock(session=session)
         get_fab_auth_manager.return_value = fab_auth_manager
 
-        out = FABAuthManagerRoles.get_permissions(limit=10, offset=0)
+        out = FABAuthManagerRoles.get_permissions(order_by="id", limit=10, 
offset=0)
         assert out.total_entries == 0
         assert out.permissions == []
 
@@ -426,7 +426,7 @@ class TestRolesService:
         fab_auth_manager.security_manager = MagicMock(session=session)
         get_fab_auth_manager.return_value = fab_auth_manager
 
-        out = FABAuthManagerRoles.get_permissions(limit=10, offset=0)
+        out = FABAuthManagerRoles.get_permissions(order_by="id", limit=10, 
offset=0)
         assert isinstance(out, PermissionCollectionResponse)
         assert out.total_entries == 2
         assert len(out.permissions) == 2
@@ -436,3 +436,43 @@ class TestRolesService:
         assert out.permissions[1] == ActionResource(
             action=Action(name="can_edit"), resource=Resource(name="DAG")
         )
+
+    
@patch("airflow.providers.fab.auth_manager.api_fastapi.services.roles.build_ordering")
+    def test_get_permissions_ordering_happy_path(self, build_ordering, 
get_fab_auth_manager):
+        perm_obj = types.SimpleNamespace(
+            action=types.SimpleNamespace(name="can_read"),
+            resource=types.SimpleNamespace(name="DAG"),
+        )
+        session = MagicMock()
+        session.scalars.side_effect = [
+            types.SimpleNamespace(one=lambda: 1),
+            types.SimpleNamespace(all=lambda: [perm_obj]),
+        ]
+        fab_auth_manager = MagicMock()
+        fab_auth_manager.security_manager = MagicMock(session=session)
+        get_fab_auth_manager.return_value = fab_auth_manager
+
+        build_ordering.return_value = column("id").desc()
+
+        out = FABAuthManagerRoles.get_permissions(order_by="-id", limit=10, 
offset=0)
+
+        assert out.total_entries == 1
+        assert len(out.permissions) == 1
+
+        build_ordering.assert_called_once()
+        args, kwargs = build_ordering.call_args
+        assert args[0] == "-id"
+        assert set(kwargs["allowed"].keys()) == {"id", "action_id", 
"resource_id"}
+
+    
@patch("airflow.providers.fab.auth_manager.api_fastapi.services.roles.build_ordering")
+    def test_get_permissions_invalid_order_by_bubbles_400(self, 
build_ordering, get_fab_auth_manager):
+        session = MagicMock()
+        fab_auth_manager = MagicMock()
+        fab_auth_manager.security_manager = MagicMock(session=session)
+        get_fab_auth_manager.return_value = fab_auth_manager
+
+        build_ordering.side_effect = HTTPException(status_code=400, 
detail="disallowed")
+
+        with pytest.raises(HTTPException) as ex:
+            FABAuthManagerRoles.get_permissions(order_by="nope", limit=10, 
offset=0)
+        assert ex.value.status_code == 400

Reply via email to