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