This is an automated email from the ASF dual-hosted git repository.
potiuk 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 144b0816760 Migrate FAB PATCH /roles/{name} to FastAPI (#58023)
144b0816760 is described below
commit 144b0816760d4010ee72120e2cb9c2369ea6ba1f
Author: kyounghoonJang <[email protected]>
AuthorDate: Mon Nov 17 07:20:10 2025 +0900
Migrate FAB PATCH /roles/{name} to FastAPI (#58023)
---
.../openapi/v2-fab-auth-manager-generated.yaml | 70 ++++++++++
.../fab/auth_manager/api_fastapi/routes/roles.py | 22 ++++
.../fab/auth_manager/api_fastapi/services/roles.py | 36 ++++++
.../auth_manager/api_fastapi/routes/test_roles.py | 142 ++++++++++++++++++++-
.../api_fastapi/services/test_roles.py | 113 +++++++++++++++-
5 files changed, 380 insertions(+), 3 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 f3f7fbfe90b..06403b34cfa 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
@@ -324,6 +324,76 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
+ patch:
+ tags:
+ - FabAuthManager
+ summary: Patch Role
+ description: Update an existing role.
+ operationId: patch_role
+ security:
+ - OAuth2PasswordBearer: []
+ - HTTPBearer: []
+ parameters:
+ - name: name
+ in: path
+ required: true
+ schema:
+ type: string
+ minLength: 1
+ title: Name
+ - name: update_mask
+ in: query
+ required: false
+ schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ description: Comma-separated list of fields to update
+ title: Update Mask
+ description: Comma-separated list of fields to update
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RoleBody'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RoleResponse'
+ '400':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPExceptionResponse'
+ description: Bad Request
+ '401':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPExceptionResponse'
+ description: Unauthorized
+ '403':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPExceptionResponse'
+ description: Forbidden
+ '404':
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPExceptionResponse'
+ description: Not Found
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
components:
schemas:
ActionResourceResponse:
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 97eb1984758..2488adbc33d 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
@@ -113,3 +113,25 @@ def get_role(name: str = Path(..., min_length=1)) ->
RoleResponse:
"""Get an existing role."""
with get_application_builder():
return FABAuthManagerRoles.get_role(name=name)
+
+
+@roles_router.patch(
+ "/roles/{name}",
+ responses=create_openapi_http_exception_doc(
+ [
+ status.HTTP_400_BAD_REQUEST,
+ status.HTTP_401_UNAUTHORIZED,
+ status.HTTP_403_FORBIDDEN,
+ status.HTTP_404_NOT_FOUND,
+ ]
+ ),
+ dependencies=[Depends(requires_fab_custom_view("PATCH",
permissions.RESOURCE_ROLE))],
+)
+def patch_role(
+ body: RoleBody,
+ name: str = Path(..., min_length=1),
+ update_mask: str | None = Query(None, description="Comma-separated list of
fields to update"),
+) -> RoleResponse:
+ """Update an existing role."""
+ with get_application_builder():
+ return FABAuthManagerRoles.patch_role(name=name, body=body,
update_mask=update_mask)
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 68d8b85d254..03bfb2e6b9b 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
@@ -120,3 +120,39 @@ class FABAuthManagerRoles:
detail=f"Role with name {name!r} does not exist.",
)
return RoleResponse.model_validate(existing)
+
+ @classmethod
+ def patch_role(cls, body: RoleBody, name: str, update_mask: str | None =
None) -> RoleResponse:
+ security_manager = get_fab_auth_manager().security_manager
+
+ existing = security_manager.find_role(name=name)
+ if not existing:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Role with name {name!r} does not exist.",
+ )
+
+ if update_mask:
+ update_data = RoleResponse.model_validate(existing)
+
+ for field in update_mask:
+ if field == "actions":
+ update_data.permissions = body.permissions
+ elif hasattr(body, field):
+ setattr(update_data, field, getattr(body, field))
+ else:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"'{field}' in update_mask is unknown",
+ )
+ else:
+ update_data = RoleResponse(name=body.name,
permissions=body.permissions or [])
+
+ 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": name, "perms": perms}])
+
+ new_name = update_data.name
+ if new_name and new_name != existing.name:
+ security_manager.update_role(role_id=existing.id, name=new_name)
+ return RoleResponse.model_validate(update_data)
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 f15e715ccd5..0d1e3b80842 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
@@ -18,7 +18,7 @@
from __future__ import annotations
from contextlib import nullcontext as _noop_cm
-from unittest.mock import MagicMock, patch
+from unittest.mock import ANY, MagicMock, patch
import pytest
from fastapi import HTTPException, status
@@ -412,3 +412,143 @@ class TestRoles:
resp = test_client.get("/fab/v1/roles/")
assert resp.status_code == 404
mock_roles.get_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_patch_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="roleA", permissions=[])
+ mock_roles.patch_role.return_value = dummy_out
+
+ with as_user():
+ resp = test_client.patch("/fab/v1/roles/roleA", json={"name":
"roleA", "actions": []})
+ assert resp.status_code == 200
+ assert resp.json() == dummy_out.model_dump(by_alias=True)
+ mock_roles.patch_role.assert_called_once_with(name="roleA",
body=ANY, update_mask=None)
+
+
@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_patch_role_with_update_mask(
+ 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="roleA", permissions=[])
+ mock_roles.patch_role.return_value = dummy_out
+
+ with as_user():
+ resp = test_client.patch(
+ "/fab/v1/roles/roleA",
+ json={"name": "roleA", "actions": []},
+ params={"update_mask": "name,actions"},
+ )
+ assert resp.status_code == 200
+ assert resp.json() == dummy_out.model_dump(by_alias=True)
+ mock_roles.patch_role.assert_called_once_with(name="roleA",
body=ANY, update_mask="name,actions")
+
+
@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_path_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.patch("/fab/v1/roles/roleA", json={"name":
"roleA", "actions": []})
+ assert resp.status_code == 403
+ mock_roles.patch_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_patch_role_validation_404_not_found(
+ 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
+ mock_roles.patch_role.side_effect = HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Role with name 'non_existent_role' does not exist.",
+ )
+
+ with as_user():
+ resp = test_client.patch(
+ "/fab/v1/roles/non_existent_role", json={"name":
"non_existent_role", "actions": []}
+ )
+ assert resp.status_code == 404
+ mock_roles.patch_role.assert_called_once_with(
+ name="non_existent_role", body=ANY, update_mask=None
+ )
+
+
@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_patch_role_validation_404_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
+ mock_roles.patch_role.side_effect = HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Role with name 'non_existent_role' does not exist.",
+ )
+
+ with as_user():
+ resp = test_client.patch("/fab/v1/roles/", json={"name":
"non_existent_role", "actions": []})
+ assert resp.status_code == 404
+
+
@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_path_role_unknown_update_mask(
+ 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
+
+ mock_roles.patch_role.side_effect = HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="unknown_field in update_mask is unknown",
+ )
+
+ with as_user():
+ resp = test_client.patch(
+ "/fab/v1/roles/roleA",
+ json={"name": "roleA", "actions": []},
+ params={"update_mask": "unknown_field"},
+ )
+ assert resp.status_code == 400
+ mock_roles.patch_role.assert_called_once_with(name="roleA",
body=ANY, update_mask="unknown_field")
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 1c9aada09ec..c46acbaef63 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
@@ -35,7 +35,7 @@ def fab_auth_manager():
@pytest.fixture
def security_manager():
sm = MagicMock()
- sm.get_action.side_effect = lambda n: object() if n in {"can_read"} else
None
+ sm.get_action.side_effect = lambda n: object() if n in {"can_read",
"can_edit"} else None
sm.get_resource.side_effect = lambda n: object() if n in {"DAG"} else None
return sm
@@ -48,7 +48,7 @@ def _make_role_obj(name: str, perms: list[tuple[str, str]]):
)
for (a, r) in perms
]
- return types.SimpleNamespace(name=name, permissions=perm_objs)
+ return types.SimpleNamespace(id=1, name=name, permissions=perm_objs)
class _FakeScalarCount:
@@ -252,3 +252,112 @@ class TestRolesService:
with pytest.raises(HTTPException) as ex:
FABAuthManagerRoles.get_role(name="roleA")
assert ex.value.status_code == 404
+
+ # PATCH /roles/{name}
+
+ def test_patch_role_success(self, get_fab_auth_manager, fab_auth_manager,
security_manager):
+ role = _make_role_obj("viewer", [("can_read", "DAG")])
+ security_manager.find_role.return_value = role
+ fab_auth_manager.security_manager = security_manager
+ get_fab_auth_manager.return_value = fab_auth_manager
+ body = types.SimpleNamespace(
+ name="viewer",
+ permissions=[
+ types.SimpleNamespace(
+ action=types.SimpleNamespace(name="can_edit"),
+ resource=types.SimpleNamespace(name="DAG"),
+ )
+ ],
+ )
+ out = FABAuthManagerRoles.patch_role(body=body, name="viewer")
+ assert out.name == "viewer"
+ assert out.permissions
+ assert out.permissions[0].action.name == "can_edit"
+ assert out.permissions[0].resource.name == "DAG"
+
+ def test_patch_role_rename_success(self, get_fab_auth_manager,
fab_auth_manager, security_manager):
+ role = _make_role_obj("viewer", [("can_edit", "DAG")])
+ security_manager.find_role.return_value = role
+ fab_auth_manager.security_manager = security_manager
+ get_fab_auth_manager.return_value = fab_auth_manager
+ body = types.SimpleNamespace(
+ name="editor",
+ permissions=[
+ types.SimpleNamespace(
+ action=types.SimpleNamespace(name="can_edit"),
+ resource=types.SimpleNamespace(name="DAG"),
+ )
+ ],
+ )
+ out = FABAuthManagerRoles.patch_role(body=body, name="viewer")
+ assert out.name == "editor"
+ assert out.permissions
+ assert out.permissions[0].action.name == "can_edit"
+ assert out.permissions[0].resource.name == "DAG"
+
+ def test_patch_role_with_update_mask(self, get_fab_auth_manager,
fab_auth_manager, security_manager):
+ role = _make_role_obj("viewer", [("can_read", "DAG")])
+ security_manager.find_role.return_value = role
+ fab_auth_manager.security_manager = security_manager
+ get_fab_auth_manager.return_value = fab_auth_manager
+ body = types.SimpleNamespace(
+ name="viewer1",
+ permissions=[
+ types.SimpleNamespace(
+ action=types.SimpleNamespace(name="can_edit"),
+ resource=types.SimpleNamespace(name="DAG"),
+ )
+ ],
+ )
+ out = FABAuthManagerRoles.patch_role(
+ body=body,
+ name="viewer",
+ update_mask=["actions"],
+ )
+ assert out.name == "viewer"
+ assert out.permissions
+ assert out.permissions[0].action.name == "can_edit"
+ assert out.permissions[0].resource.name == "DAG"
+
+ def test_patch_role_rename_with_update_mask(
+ self, get_fab_auth_manager, fab_auth_manager, security_manager
+ ):
+ role = _make_role_obj("viewer", [("can_read", "DAG")])
+ security_manager.find_role.return_value = role
+ fab_auth_manager.security_manager = security_manager
+ get_fab_auth_manager.return_value = fab_auth_manager
+ body = types.SimpleNamespace(
+ name="viewer1",
+ permissions=[
+ types.SimpleNamespace(
+ action=types.SimpleNamespace(name="can_edit"),
+ resource=types.SimpleNamespace(name="DAG"),
+ )
+ ],
+ )
+ out = FABAuthManagerRoles.patch_role(
+ body=body,
+ name="viewer",
+ update_mask=["name"],
+ )
+ assert out.name == "viewer1"
+ assert out.permissions
+ assert out.permissions[0].action.name == "can_read"
+ assert out.permissions[0].resource.name == "DAG"
+
+ def test_patch_role_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
+ body = types.SimpleNamespace(
+ name="viewer",
+ permissions=[
+ types.SimpleNamespace(
+ action=types.SimpleNamespace(name="can_edit"),
+ resource=types.SimpleNamespace(name="DAG"),
+ )
+ ],
+ )
+ with pytest.raises(HTTPException) as ex:
+ FABAuthManagerRoles.patch_role(body=body, name="viewer")
+ assert ex.value.status_code == 404