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 d9035cffed1 Migrate FAB POST /roles to FastAPI (#57199)
d9035cffed1 is described below

commit d9035cffed1cf8a400807879c83ae501587ff3fa
Author: Yun-Ting Chiu <[email protected]>
AuthorDate: Mon Oct 27 21:22:49 2025 +0800

    Migrate FAB POST /roles to FastAPI (#57199)
    
    * Migrate FAB POST /roles to FastAPI
    
    * Remove unnecessary casting and model
    
    * Validate non-empty role name via Pydantic
    
    * Move auth dep to provider security and fixtureize dependency_overrides
    
    * Add test for roles data model and security provider
---
 .../auth_manager/api_fastapi/datamodels/roles.py   |  56 ++++++++
 .../openapi/v2-fab-auth-manager-generated.yaml     | 141 +++++++++++++++++++++
 .../fab/auth_manager/api_fastapi/routes/roles.py   |  54 ++++++++
 .../fab/auth_manager/api_fastapi/security.py}      |  20 +--
 .../fab/auth_manager/api_fastapi/services/roles.py |  74 +++++++++++
 .../providers/fab/auth_manager/fab_auth_manager.py |   2 +
 .../unit/fab/auth_manager/api_fastapi/conftest.py  |  38 ++++++
 .../{conftest.py => datamodels/__init__.py}        |  16 ---
 .../api_fastapi/datamodels/test_roles.py           |  88 +++++++++++++
 .../auth_manager/api_fastapi/routes/test_roles.py  | 104 +++++++++++++++
 .../api_fastapi/services/test_roles.py             | 141 +++++++++++++++++++++
 .../fab/auth_manager/api_fastapi/test_security.py  |  50 ++++++++
 12 files changed, 758 insertions(+), 26 deletions(-)

diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/datamodels/roles.py
 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/datamodels/roles.py
new file mode 100644
index 00000000000..ce116e97724
--- /dev/null
+++ 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/datamodels/roles.py
@@ -0,0 +1,56 @@
+# 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 pydantic import Field
+
+from airflow.api_fastapi.core_api.base import BaseModel, StrictBaseModel
+
+
+class ActionResponse(BaseModel):
+    """Outgoing representation of an action (permission name)."""
+
+    name: str
+
+
+class ResourceResponse(BaseModel):
+    """Outgoing representation of a resource."""
+
+    name: str
+
+
+class ActionResourceResponse(BaseModel):
+    """Pairing of an action with a resource."""
+
+    action: ActionResponse
+    resource: ResourceResponse
+
+
+class RoleBody(StrictBaseModel):
+    """Incoming payload for creating/updating a role."""
+
+    name: str = Field(min_length=1)
+    permissions: list[ActionResourceResponse] = Field(
+        default_factory=list, alias="actions", validation_alias="actions"
+    )
+
+
+class RoleResponse(BaseModel):
+    """Outgoing representation of a role and its permissions."""
+
+    name: str
+    permissions: list[ActionResourceResponse] = Field(default_factory=list, 
serialization_alias="actions")
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 15503e4d4bb..4a29bcf2fb3 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
@@ -82,8 +82,89 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/HTTPValidationError'
+  /auth/fab/v1/roles:
+    post:
+      tags:
+      - FabAuthManager
+      summary: Create Role
+      description: Create a new role (actions can be empty).
+      operationId: create_role
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/RoleBody'
+        required: true
+      responses:
+        '200':
+          description: Successful Response
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/RoleResponse'
+        '400':
+          description: Bad Request
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
+        '401':
+          description: Unauthorized
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
+        '403':
+          description: Forbidden
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
+        '409':
+          description: Conflict
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
+        '500':
+          description: Internal Server Error
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPExceptionResponse'
+        '422':
+          description: Validation Error
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/HTTPValidationError'
+      security:
+      - OAuth2PasswordBearer: []
+      - HTTPBearer: []
 components:
   schemas:
+    ActionResourceResponse:
+      properties:
+        action:
+          $ref: '#/components/schemas/ActionResponse'
+        resource:
+          $ref: '#/components/schemas/ResourceResponse'
+      type: object
+      required:
+      - action
+      - resource
+      title: ActionResourceResponse
+      description: Pairing of an action with a resource.
+    ActionResponse:
+      properties:
+        name:
+          type: string
+          title: Name
+      type: object
+      required:
+      - name
+      title: ActionResponse
+      description: Outgoing representation of an action (permission name).
     HTTPExceptionResponse:
       properties:
         detail:
@@ -130,6 +211,48 @@ components:
       - access_token
       title: LoginResponse
       description: API Token serializer for responses.
+    ResourceResponse:
+      properties:
+        name:
+          type: string
+          title: Name
+      type: object
+      required:
+      - name
+      title: ResourceResponse
+      description: Outgoing representation of a resource.
+    RoleBody:
+      properties:
+        name:
+          type: string
+          minLength: 1
+          title: Name
+        actions:
+          items:
+            $ref: '#/components/schemas/ActionResourceResponse'
+          type: array
+          title: Actions
+      additionalProperties: false
+      type: object
+      required:
+      - name
+      title: RoleBody
+      description: Incoming payload for creating/updating a role.
+    RoleResponse:
+      properties:
+        name:
+          type: string
+          title: Name
+        actions:
+          items:
+            $ref: '#/components/schemas/ActionResourceResponse'
+          type: array
+          title: Actions
+      type: object
+      required:
+      - name
+      title: RoleResponse
+      description: Outgoing representation of a role and its permissions.
     ValidationError:
       properties:
         loc:
@@ -151,3 +274,21 @@ components:
       - msg
       - type
       title: ValidationError
+  securitySchemes:
+    OAuth2PasswordBearer:
+      type: oauth2
+      description: To authenticate Airflow API requests, clients must include 
a JWT
+        (JSON Web Token) in the Authorization header of each request. This 
token is
+        used to verify the identity of the client and ensure that they have 
the appropriate
+        permissions to access the requested resources. You can use the 
endpoint ``POST
+        /auth/token`` in order to generate a JWT token. Upon successful 
authentication,
+        the server will issue a JWT token that contains the necessary 
information
+        (such as user identity and scope) to authenticate subsequent requests. 
To
+        learn more about Airflow public API authentication, please read 
https://airflow.apache.org/docs/apache-airflow/stable/security/api.html.
+      flows:
+        password:
+          scopes: {}
+          tokenUrl: /auth/token
+    HTTPBearer:
+      type: http
+      scheme: bearer
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
new file mode 100644
index 00000000000..a7b7ccbec04
--- /dev/null
+++ 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/roles.py
@@ -0,0 +1,54 @@
+# 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
+
+from fastapi import Depends, status
+
+from airflow.api_fastapi.common.router import AirflowRouter
+from airflow.api_fastapi.core_api.openapi.exceptions import 
create_openapi_http_exception_doc
+from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import 
RoleBody, RoleResponse
+from airflow.providers.fab.auth_manager.api_fastapi.security import 
requires_fab_custom_view
+from airflow.providers.fab.auth_manager.api_fastapi.services.roles import 
FABAuthManagerRoles
+from airflow.providers.fab.auth_manager.cli_commands.utils import 
get_application_builder
+from airflow.providers.fab.www.security import permissions
+
+if TYPE_CHECKING:
+    from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles 
import RoleBody, RoleResponse
+
+
+roles_router = AirflowRouter(prefix="/fab/v1", tags=["FabAuthManager"])
+
+
+@roles_router.post(
+    "/roles",
+    responses=create_openapi_http_exception_doc(
+        [
+            status.HTTP_400_BAD_REQUEST,
+            status.HTTP_401_UNAUTHORIZED,
+            status.HTTP_403_FORBIDDEN,
+            status.HTTP_409_CONFLICT,
+            status.HTTP_500_INTERNAL_SERVER_ERROR,
+        ]
+    ),
+    dependencies=[Depends(requires_fab_custom_view("POST", 
permissions.RESOURCE_ROLE))],
+)
+def create_role(body: RoleBody) -> RoleResponse:
+    """Create a new role (actions can be empty)."""
+    with get_application_builder():
+        return FABAuthManagerRoles.create_role(body=body)
diff --git a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/security.py
similarity index 61%
copy from providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py
copy to 
providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/security.py
index 86273a0af74..90c4d8b6f15 100644
--- a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py
+++ 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/security.py
@@ -16,17 +16,17 @@
 # under the License.
 from __future__ import annotations
 
-import pytest
-from fastapi.testclient import TestClient
+from fastapi import Depends, HTTPException, status
 
-from airflow.providers.fab.auth_manager.fab_auth_manager import FabAuthManager
+from airflow.api_fastapi.app import get_auth_manager
+from airflow.api_fastapi.core_api.security import get_user
 
 
[email protected](scope="module")
-def fab_auth_manager():
-    return FabAuthManager(None)
+def requires_fab_custom_view(method: str, resource_name: str):
+    def _check(user=Depends(get_user)):
+        if not get_auth_manager().is_authorized_custom_view(
+            method=method, resource_name=resource_name, user=user
+        ):
+            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, 
detail="Forbidden")
 
-
[email protected](scope="module")
-def test_client(fab_auth_manager):
-    return TestClient(fab_auth_manager.get_fastapi_app())
+    return _check
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
new file mode 100644
index 00000000000..0997eec976a
--- /dev/null
+++ 
b/providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/roles.py
@@ -0,0 +1,74 @@
+# 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
+
+from fastapi import HTTPException, status
+
+from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import 
RoleBody, RoleResponse
+from airflow.providers.fab.www.utils import get_fab_auth_manager
+
+if TYPE_CHECKING:
+    from airflow.providers.fab.auth_manager.security_manager.override import 
FabAirflowSecurityManagerOverride
+
+
+class FABAuthManagerRoles:
+    """Service layer for FAB Auth Manager role operations (create, validate, 
sync)."""
+
+    @staticmethod
+    def _check_action_and_resource(
+        security_manager: FabAirflowSecurityManagerOverride,
+        perms: list[tuple[str, str]],
+    ) -> None:
+        for action_name, resource_name in perms:
+            if not security_manager.get_action(action_name):
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail=f"The specified action: {action_name!r} was not 
found",
+                )
+            if not security_manager.get_resource(resource_name):
+                raise HTTPException(
+                    status_code=status.HTTP_400_BAD_REQUEST,
+                    detail=f"The specified resource: {resource_name!r} was not 
found",
+                )
+
+    @classmethod
+    def create_role(cls, body: RoleBody) -> RoleResponse:
+        security_manager = get_fab_auth_manager().security_manager
+
+        existing = security_manager.find_role(name=body.name)
+        if existing:
+            raise HTTPException(
+                status_code=status.HTTP_409_CONFLICT,
+                detail=f"Role with name {body.name!r} already exists; please 
update with the PATCH endpoint",
+            )
+
+        perms: list[tuple[str, str]] = [(ar.action.name, ar.resource.name) for 
ar in (body.permissions or [])]
+
+        cls._check_action_and_resource(security_manager, perms)
+
+        security_manager.bulk_sync_roles([{"role": body.name, "perms": perms}])
+
+        created = security_manager.find_role(name=body.name)
+        if not created:
+            raise HTTPException(
+                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+                detail="Role was not created due to an unexpected error.",
+            )
+
+        return RoleResponse.model_validate(created)
diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py 
b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
index 3f423810f17..55db1a64e26 100644
--- a/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
+++ b/providers/fab/src/airflow/providers/fab/auth_manager/fab_auth_manager.py
@@ -226,6 +226,7 @@ class FabAuthManager(BaseAuthManager[User]):
         from airflow.providers.fab.auth_manager.api_fastapi.routes.login 
import (
             login_router,
         )
+        from airflow.providers.fab.auth_manager.api_fastapi.routes.roles 
import roles_router
 
         flask_app = create_app(enable_plugins=False)
 
@@ -241,6 +242,7 @@ class FabAuthManager(BaseAuthManager[User]):
 
         # Add the login router to the FastAPI app
         app.include_router(login_router)
+        app.include_router(roles_router)
 
         app.mount("/", WSGIMiddleware(flask_app))
 
diff --git a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py 
b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py
index 86273a0af74..7e4d090b201 100644
--- a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py
+++ b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py
@@ -16,9 +16,13 @@
 # under the License.
 from __future__ import annotations
 
+import types
+from contextlib import contextmanager
+
 import pytest
 from fastapi.testclient import TestClient
 
+from airflow.api_fastapi.core_api.security import get_user as get_user_dep
 from airflow.providers.fab.auth_manager.fab_auth_manager import FabAuthManager
 
 
@@ -30,3 +34,37 @@ def fab_auth_manager():
 @pytest.fixture(scope="module")
 def test_client(fab_auth_manager):
     return TestClient(fab_auth_manager.get_fastapi_app())
+
+
[email protected]
+def override_deps(test_client):
+    """
+    Context-managed helper for app.dependency_overrides.
+
+    Usage:
+        with override_deps({dep_func: override_func, ...}):
+            # do requests
+    """
+    app = test_client.app
+
+    @contextmanager
+    def _use(mapping: dict):
+        for dep, override in mapping.items():
+            app.dependency_overrides[dep] = override
+        try:
+            yield
+        finally:
+            for dep in mapping.keys():
+                app.dependency_overrides.pop(dep, None)
+
+    return _use
+
+
[email protected]
+def as_user(override_deps):
+    @contextmanager
+    def _as(u=types.SimpleNamespace(id=1, username="tester")):
+        with override_deps({get_user_dep: lambda: u}):
+            yield u
+
+    return _as
diff --git a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py 
b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/datamodels/__init__.py
similarity index 66%
copy from providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py
copy to 
providers/fab/tests/unit/fab/auth_manager/api_fastapi/datamodels/__init__.py
index 86273a0af74..13a83393a91 100644
--- a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/conftest.py
+++ 
b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/datamodels/__init__.py
@@ -14,19 +14,3 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-from __future__ import annotations
-
-import pytest
-from fastapi.testclient import TestClient
-
-from airflow.providers.fab.auth_manager.fab_auth_manager import FabAuthManager
-
-
[email protected](scope="module")
-def fab_auth_manager():
-    return FabAuthManager(None)
-
-
[email protected](scope="module")
-def test_client(fab_auth_manager):
-    return TestClient(fab_auth_manager.get_fastapi_app())
diff --git 
a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/datamodels/test_roles.py
 
b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/datamodels/test_roles.py
new file mode 100644
index 00000000000..c826e0f100e
--- /dev/null
+++ 
b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/datamodels/test_roles.py
@@ -0,0 +1,88 @@
+# 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
+
+import types
+
+import pytest
+from pydantic import ValidationError
+
+from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import (
+    ActionResourceResponse,
+    ActionResponse,
+    ResourceResponse,
+    RoleBody,
+    RoleResponse,
+)
+
+
+class TestRoleModels:
+    def test_rolebody_accepts_actions_alias_and_maps_to_permissions(self):
+        data = {
+            "name": "viewer",
+            "actions": [
+                {"action": {"name": "can_read"}, "resource": {"name": "DAG"}},
+                {"action": {"name": "can_read"}, "resource": {"name": 
"Connection"}},
+            ],
+        }
+        body = RoleBody.model_validate(data)
+        assert body.name == "viewer"
+        # Field(validation_alias="actions") should populate `permissions`
+        assert len(body.permissions) == 2
+        assert body.permissions[0].action.name == "can_read"
+        assert body.permissions[0].resource.name == "DAG"
+
+    def test_rolebody_defaults_permissions_to_empty_when_actions_missing(self):
+        body = RoleBody.model_validate({"name": "empty"})
+        assert body.name == "empty"
+        assert body.permissions == []
+
+    def test_rolebody_name_min_length_enforced(self):
+        with pytest.raises(ValidationError):
+            RoleBody.model_validate({"name": "", "actions": []})
+
+    def test_roleresponse_serializes_permissions_under_actions_alias(self):
+        ar = ActionResourceResponse(
+            action=ActionResponse(name="can_read"),
+            resource=ResourceResponse(name="DAG"),
+        )
+        rr = RoleResponse(name="viewer", permissions=[ar])
+
+        dumped = rr.model_dump(by_alias=True)
+        # Field(serialization_alias="actions") should rename `permissions` -> 
`actions`
+        assert "actions" in dumped
+        assert "permissions" not in dumped
+        assert dumped["name"] == "viewer"
+        assert dumped["actions"][0]["action"]["name"] == "can_read"
+        assert dumped["actions"][0]["resource"]["name"] == "DAG"
+
+    def test_roleresponse_model_validate_from_simple_namespace(self):
+        # Service returns plain objects; ensure model_validate handles them
+        obj = types.SimpleNamespace(
+            name="viewer",
+            permissions=[
+                types.SimpleNamespace(
+                    action=types.SimpleNamespace(name="can_read"),
+                    resource=types.SimpleNamespace(name="DAG"),
+                )
+            ],
+        )
+        rr = RoleResponse.model_validate(obj)
+        assert rr.name == "viewer"
+        assert rr.permissions
+        first = rr.permissions[0]
+        assert first.action.name == "can_read"
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
new file mode 100644
index 00000000000..970003aa053
--- /dev/null
+++ b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/test_roles.py
@@ -0,0 +1,104 @@
+# 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 contextlib import nullcontext as _noop_cm
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import 
RoleResponse
+
+
[email protected]_test
+class TestRoles:
+    
@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(),
+    )
+    def test_create_role(
+        self, mock_get_application_builder, mock_get_auth_manager, mock_roles, 
test_client, as_user
+    ):
+        mgr = MagicMock()
+        mgr.is_authorized_custom_view.return_value = True
+        mock_get_auth_manager.return_value = mgr
+
+        dummy_out = RoleResponse(name="my_new_role", permissions=[])
+        mock_roles.create_role.return_value = dummy_out
+
+        with as_user():
+            resp = test_client.post("/fab/v1/roles", json={"name": 
"my_new_role", "actions": []})
+            assert resp.status_code == 200
+            assert resp.json() == dummy_out.model_dump(by_alias=True)
+            mock_roles.create_role.assert_called_once()
+
+    
@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(),
+    )
+    def test_create_role_forbidden(
+        self, mock_get_application_builder, mock_get_auth_manager, mock_roles, 
test_client, as_user
+    ):
+        mgr = MagicMock()
+        mgr.is_authorized_custom_view.return_value = False
+        mock_get_auth_manager.return_value = mgr
+
+        with as_user():
+            resp = test_client.post("/fab/v1/roles", json={"name": "r", 
"actions": []})
+            assert resp.status_code == 403
+            mock_roles.create_role.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(),
+    )
+    def test_create_role_validation_422_empty_name(
+        self, mock_get_application_builder, mock_get_auth_manager, mock_roles, 
test_client, as_user
+    ):
+        mgr = MagicMock()
+        mgr.is_authorized_custom_view.return_value = True
+        mock_get_auth_manager.return_value = mgr
+
+        with as_user():
+            resp = test_client.post("/fab/v1/roles", json={"name": "", 
"actions": []})
+            assert resp.status_code == 422
+            mock_roles.create_role.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(),
+    )
+    def test_create_role_validation_422_missing_name(
+        self, mock_get_application_builder, mock_get_auth_manager, mock_roles, 
test_client, as_user
+    ):
+        mgr = MagicMock()
+        mgr.is_authorized_custom_view.return_value = True
+        mock_get_auth_manager.return_value = mgr
+
+        with as_user():
+            resp = test_client.post("/fab/v1/roles", json={"actions": []})
+            assert resp.status_code == 422
+            mock_roles.create_role.assert_not_called()
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
new file mode 100644
index 00000000000..a01ce464aa6
--- /dev/null
+++ 
b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/test_roles.py
@@ -0,0 +1,141 @@
+# 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
+
+import types
+from unittest.mock import MagicMock, patch
+
+import pytest
+from fastapi import HTTPException
+
+from airflow.providers.fab.auth_manager.api_fastapi.services.roles import 
FABAuthManagerRoles
+
+
[email protected]
+def fab_auth_manager():
+    return MagicMock()
+
+
[email protected]
+def security_manager():
+    sm = MagicMock()
+    sm.get_action.side_effect = lambda n: object() if n in {"can_read"} else 
None
+    sm.get_resource.side_effect = lambda n: object() if n in {"DAG"} else None
+    return sm
+
+
+def _make_role_obj(name: str, perms: list[tuple[str, str]]):
+    perm_objs = [
+        types.SimpleNamespace(
+            action=types.SimpleNamespace(name=a),
+            resource=types.SimpleNamespace(name=r),
+        )
+        for (a, r) in perms
+    ]
+    return types.SimpleNamespace(name=name, permissions=perm_objs)
+
+
+@patch("airflow.providers.fab.auth_manager.api_fastapi.services.roles.get_fab_auth_manager")
+class TestRolesService:
+    def setup_method(self):
+        self.body_ok = types.SimpleNamespace(
+            name="roleA",
+            permissions=[
+                types.SimpleNamespace(
+                    action=types.SimpleNamespace(name="can_read"),
+                    resource=types.SimpleNamespace(name="DAG"),
+                )
+            ],
+        )
+
+        self.body_bad_action = types.SimpleNamespace(
+            name="roleB",
+            permissions=[
+                types.SimpleNamespace(
+                    action=types.SimpleNamespace(name="no_such_action"),
+                    resource=types.SimpleNamespace(name="DAG"),
+                )
+            ],
+        )
+        self.body_bad_resource = types.SimpleNamespace(
+            name="roleC",
+            permissions=[
+                types.SimpleNamespace(
+                    action=types.SimpleNamespace(name="can_read"),
+                    resource=types.SimpleNamespace(name="NOPE"),
+                )
+            ],
+        )
+
+    def test_create_role_success(self, get_fab_auth_manager, fab_auth_manager, 
security_manager):
+        security_manager.find_role.side_effect = [
+            None,
+            _make_role_obj("roleA", [("can_read", "DAG")]),
+        ]
+        fab_auth_manager.security_manager = security_manager
+        get_fab_auth_manager.return_value = fab_auth_manager
+
+        out = FABAuthManagerRoles.create_role(self.body_ok)
+
+        assert out.name == "roleA"
+        assert out.permissions
+        assert out.permissions[0].action.name == "can_read"
+        assert out.permissions[0].resource.name == "DAG"
+        security_manager.bulk_sync_roles.assert_called_once_with(
+            [{"role": "roleA", "perms": [("can_read", "DAG")]}]
+        )
+
+    def test_create_role_conflict(self, get_fab_auth_manager, 
fab_auth_manager, security_manager):
+        security_manager.find_role.return_value = object()
+        fab_auth_manager.security_manager = security_manager
+        get_fab_auth_manager.return_value = fab_auth_manager
+
+        with pytest.raises(HTTPException) as ex:
+            FABAuthManagerRoles.create_role(self.body_ok)
+        assert ex.value.status_code == 409
+
+    def test_create_role_action_not_found(self, get_fab_auth_manager, 
fab_auth_manager, security_manager):
+        security_manager.find_role.return_value = None
+        fab_auth_manager.security_manager = security_manager
+        get_fab_auth_manager.return_value = fab_auth_manager
+
+        with pytest.raises(HTTPException) as ex:
+            FABAuthManagerRoles.create_role(self.body_bad_action)
+        assert ex.value.status_code == 400
+        assert "action" in ex.value.detail
+
+    def test_create_role_resource_not_found(self, get_fab_auth_manager, 
fab_auth_manager, security_manager):
+        security_manager.find_role.return_value = None
+        fab_auth_manager.security_manager = security_manager
+        get_fab_auth_manager.return_value = fab_auth_manager
+
+        with pytest.raises(HTTPException) as ex:
+            FABAuthManagerRoles.create_role(self.body_bad_resource)
+        assert ex.value.status_code == 400
+        assert "resource" in ex.value.detail
+
+    def test_create_role_unexpected_no_created(
+        self, get_fab_auth_manager, fab_auth_manager, security_manager
+    ):
+        security_manager.find_role.side_effect = [None, None]
+        fab_auth_manager.security_manager = security_manager
+        get_fab_auth_manager.return_value = fab_auth_manager
+
+        with pytest.raises(HTTPException) as ex:
+            FABAuthManagerRoles.create_role(self.body_ok)
+        assert ex.value.status_code == 500
diff --git 
a/providers/fab/tests/unit/fab/auth_manager/api_fastapi/test_security.py 
b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/test_security.py
new file mode 100644
index 00000000000..ae5830af290
--- /dev/null
+++ b/providers/fab/tests/unit/fab/auth_manager/api_fastapi/test_security.py
@@ -0,0 +1,50 @@
+# 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 unittest.mock import MagicMock, patch
+
+import pytest
+from fastapi import HTTPException
+
+from airflow.providers.fab.auth_manager.api_fastapi.security import 
requires_fab_custom_view
+
+
+class TestSecurityDependency:
+    
@patch("airflow.providers.fab.auth_manager.api_fastapi.security.get_auth_manager")
+    def test_requires_fab_custom_view_allows_when_authorized(self, 
get_auth_manager):
+        mgr = MagicMock()
+        mgr.is_authorized_custom_view.return_value = True
+        get_auth_manager.return_value = mgr
+
+        check = requires_fab_custom_view(method="POST", resource_name="Role")
+        user = object()
+
+        assert check(user=user) is None
+        mgr.is_authorized_custom_view.assert_called_once_with(method="POST", 
resource_name="Role", user=user)
+
+    
@patch("airflow.providers.fab.auth_manager.api_fastapi.security.get_auth_manager")
+    def test_requires_fab_custom_view_raises_403_when_unauthorized(self, 
get_auth_manager):
+        mgr = MagicMock()
+        mgr.is_authorized_custom_view.return_value = False
+        get_auth_manager.return_value = mgr
+
+        check = requires_fab_custom_view(method="DELETE", resource_name="Role")
+        with pytest.raises(HTTPException) as ex:
+            check(user=object())
+        assert ex.value.status_code == 403


Reply via email to