Skip to content

Commit 8c96236

Browse files
authored
Migrate Flask based role_and_permission_endpoint APIs to Fastapi (#60977)
1 parent c1c8d4b commit 8c96236

File tree

7 files changed

+283
-2
lines changed

7 files changed

+283
-2
lines changed

providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/datamodels/roles.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,10 @@ class RoleCollectionResponse(BaseModel):
6767

6868
roles: list[RoleResponse]
6969
total_entries: int
70+
71+
72+
class PermissionCollectionResponse(BaseModel):
73+
"""Outgoing representation of a paginated collection of permissions."""
74+
75+
permissions: list[ActionResource] = Field(default_factory=list, serialization_alias="actions")
76+
total_entries: int

providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/openapi/v2-fab-auth-manager-generated.yaml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,62 @@ paths:
398398
application/json:
399399
schema:
400400
$ref: '#/components/schemas/HTTPValidationError'
401+
/auth/fab/v1/permissions:
402+
get:
403+
tags:
404+
- FabAuthManager
405+
summary: Get Permissions
406+
description: List all action-resource (permission) pairs.
407+
operationId: get_permissions
408+
security:
409+
- OAuth2PasswordBearer: []
410+
- HTTPBearer: []
411+
parameters:
412+
- name: limit
413+
in: query
414+
required: false
415+
schema:
416+
type: integer
417+
default: 100
418+
title: Limit
419+
- name: offset
420+
in: query
421+
required: false
422+
schema:
423+
type: integer
424+
default: 0
425+
title: Offset
426+
responses:
427+
'200':
428+
description: Successful Response
429+
content:
430+
application/json:
431+
schema:
432+
$ref: '#/components/schemas/PermissionCollectionResponse'
433+
'401':
434+
content:
435+
application/json:
436+
schema:
437+
$ref: '#/components/schemas/HTTPExceptionResponse'
438+
description: Unauthorized
439+
'403':
440+
content:
441+
application/json:
442+
schema:
443+
$ref: '#/components/schemas/HTTPExceptionResponse'
444+
description: Forbidden
445+
'500':
446+
content:
447+
application/json:
448+
schema:
449+
$ref: '#/components/schemas/HTTPExceptionResponse'
450+
description: Internal Server Error
451+
'422':
452+
description: Validation Error
453+
content:
454+
application/json:
455+
schema:
456+
$ref: '#/components/schemas/HTTPValidationError'
401457
/auth/fab/v1/users:
402458
post:
403459
tags:
@@ -749,6 +805,21 @@ components:
749805
- access_token
750806
title: LoginResponse
751807
description: API Token serializer for responses.
808+
PermissionCollectionResponse:
809+
properties:
810+
actions:
811+
items:
812+
$ref: '#/components/schemas/ActionResource'
813+
type: array
814+
title: Actions
815+
total_entries:
816+
type: integer
817+
title: Total Entries
818+
type: object
819+
required:
820+
- total_entries
821+
title: PermissionCollectionResponse
822+
description: Outgoing representation of a paginated collection of permissions.
752823
Resource:
753824
properties:
754825
name:

providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/routes/roles.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from airflow.api_fastapi.common.router import AirflowRouter
2424
from airflow.api_fastapi.core_api.openapi.exceptions import create_openapi_http_exception_doc
2525
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import (
26+
PermissionCollectionResponse,
2627
RoleBody,
2728
RoleCollectionResponse,
2829
RoleResponse,
@@ -135,3 +136,21 @@ def patch_role(
135136
"""Update an existing role."""
136137
with get_application_builder():
137138
return FABAuthManagerRoles.patch_role(name=name, body=body, update_mask=update_mask)
139+
140+
141+
@roles_router.get(
142+
"/permissions",
143+
response_model=PermissionCollectionResponse,
144+
responses=create_openapi_http_exception_doc(
145+
[
146+
status.HTTP_401_UNAUTHORIZED,
147+
status.HTTP_403_FORBIDDEN,
148+
status.HTTP_500_INTERNAL_SERVER_ERROR,
149+
]
150+
),
151+
dependencies=[Depends(requires_fab_custom_view("GET", permissions.RESOURCE_ROLE))],
152+
)
153+
def get_permissions(limit: int = Query(100), offset: int = Query(0)):
154+
"""List all action-resource (permission) pairs."""
155+
with get_application_builder():
156+
return FABAuthManagerRoles.get_permissions(limit=limit, offset=offset)

providers/fab/src/airflow/providers/fab/auth_manager/api_fastapi/services/roles.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,19 @@
2020

2121
from fastapi import HTTPException, status
2222
from sqlalchemy import func, select
23+
from sqlalchemy.orm import joinedload
2324

2425
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import (
26+
Action as ActionModel,
27+
ActionResource,
28+
PermissionCollectionResponse,
29+
Resource as ResourceModel,
2530
RoleBody,
2631
RoleCollectionResponse,
2732
RoleResponse,
2833
)
2934
from airflow.providers.fab.auth_manager.api_fastapi.sorting import build_ordering
30-
from airflow.providers.fab.auth_manager.models import Role
35+
from airflow.providers.fab.auth_manager.models import Permission, Role
3136
from airflow.providers.fab.www.utils import get_fab_auth_manager
3237

3338
if TYPE_CHECKING:
@@ -156,3 +161,25 @@ def patch_role(cls, body: RoleBody, name: str, update_mask: str | None = None) -
156161
if new_name and new_name != existing.name:
157162
security_manager.update_role(role_id=existing.id, name=new_name)
158163
return RoleResponse.model_validate(update_data)
164+
165+
@classmethod
166+
def get_permissions(cls, *, limit: int, offset: int) -> PermissionCollectionResponse:
167+
security_manager = get_fab_auth_manager().security_manager
168+
session = security_manager.session
169+
total_entries = session.scalars(select(func.count(Permission.id))).one()
170+
query = (
171+
select(Permission)
172+
.options(joinedload(Permission.action), joinedload(Permission.resource))
173+
.offset(offset)
174+
.limit(limit)
175+
)
176+
permissions = session.scalars(query).all()
177+
return PermissionCollectionResponse(
178+
permissions=[
179+
ActionResource(
180+
action=ActionModel(name=p.action.name), resource=ResourceModel(name=p.resource.name)
181+
)
182+
for p in permissions
183+
],
184+
total_entries=total_entries,
185+
)

providers/fab/tests/unit/fab/auth_manager/api_fastapi/datamodels/test_roles.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import (
2525
Action,
2626
ActionResource,
27+
PermissionCollectionResponse,
2728
Resource,
2829
RoleBody,
2930
RoleCollectionResponse,
@@ -128,3 +129,37 @@ def test_rolecollection_model_validate_from_objects(self):
128129
def test_rolecollection_missing_total_entries_raises(self):
129130
with pytest.raises(ValidationError):
130131
RoleCollectionResponse.model_validate({"roles": []})
132+
133+
def test_permission_collection_response_valid(self):
134+
ar = ActionResource(
135+
action=Action(name="can_read"),
136+
resource=Resource(name="DAG"),
137+
)
138+
resp = PermissionCollectionResponse(
139+
permissions=[ar],
140+
total_entries=1,
141+
)
142+
dumped = resp.model_dump()
143+
assert dumped["total_entries"] == 1
144+
assert isinstance(dumped["permissions"], list)
145+
assert dumped["permissions"][0]["action"]["name"] == "can_read"
146+
assert dumped["permissions"][0]["resource"]["name"] == "DAG"
147+
148+
def test_permission_collection_response_model_validate_from_objects(self):
149+
obj = types.SimpleNamespace(
150+
permissions=[
151+
types.SimpleNamespace(
152+
action=types.SimpleNamespace(name="can_read"),
153+
resource=types.SimpleNamespace(name="DAG"),
154+
)
155+
],
156+
total_entries=1,
157+
)
158+
resp = PermissionCollectionResponse.model_validate(obj)
159+
assert resp.total_entries == 1
160+
assert len(resp.permissions) == 1
161+
assert resp.permissions[0].action.name == "can_read"
162+
163+
def test_permission_collection_missing_total_entries_raises(self):
164+
with pytest.raises(ValidationError):
165+
PermissionCollectionResponse.model_validate({"permissions": []})

providers/fab/tests/unit/fab/auth_manager/api_fastapi/routes/test_roles.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
from fastapi import HTTPException, status
2525

2626
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import (
27+
Action,
28+
ActionResource,
29+
PermissionCollectionResponse,
30+
Resource,
2731
RoleCollectionResponse,
2832
RoleResponse,
2933
)
@@ -552,3 +556,46 @@ def test_path_role_unknown_update_mask(
552556
)
553557
assert resp.status_code == 400
554558
mock_roles.patch_role.assert_called_once_with(name="roleA", body=ANY, update_mask="unknown_field")
559+
560+
@patch("airflow.providers.fab.auth_manager.api_fastapi.routes.roles.FABAuthManagerRoles")
561+
@patch("airflow.providers.fab.auth_manager.api_fastapi.security.get_auth_manager")
562+
@patch(
563+
"airflow.providers.fab.auth_manager.api_fastapi.routes.roles.get_application_builder",
564+
return_value=_noop_cm(),
565+
)
566+
def test_get_permissions_success(
567+
self, mock_get_application_builder, mock_get_auth_manager, mock_permissions, test_client, as_user
568+
):
569+
mgr = MagicMock()
570+
mgr.is_authorized_custom_view.return_value = True
571+
mock_get_auth_manager.return_value = mgr
572+
573+
dummy = PermissionCollectionResponse(
574+
permissions=[ActionResource(action=Action(name="can_read"), resource=Resource(name="DAG"))],
575+
total_entries=1,
576+
)
577+
mock_permissions.get_permissions.return_value = dummy
578+
579+
with as_user():
580+
resp = test_client.get("/fab/v1/permissions")
581+
assert resp.status_code == 200
582+
assert resp.json() == dummy.model_dump(by_alias=True)
583+
mock_permissions.get_permissions.assert_called_once_with(limit=100, offset=0)
584+
585+
@patch("airflow.providers.fab.auth_manager.api_fastapi.routes.roles.FABAuthManagerRoles")
586+
@patch("airflow.providers.fab.auth_manager.api_fastapi.security.get_auth_manager")
587+
@patch(
588+
"airflow.providers.fab.auth_manager.api_fastapi.routes.roles.get_application_builder",
589+
return_value=_noop_cm(),
590+
)
591+
def test_get_permissions_forbidden(
592+
self, mock_get_application_builder, mock_get_auth_manager, mock_permissions, test_client, as_user
593+
):
594+
mgr = MagicMock()
595+
mgr.is_authorized_custom_view.return_value = False
596+
mock_get_auth_manager.return_value = mgr
597+
598+
with as_user():
599+
resp = test_client.get("/fab/v1/permissions")
600+
assert resp.status_code == 403
601+
mock_permissions.get_permissions.assert_not_called()

providers/fab/tests/unit/fab/auth_manager/api_fastapi/services/test_roles.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,15 @@
2424
from fastapi import HTTPException
2525
from sqlalchemy import column
2626

27-
from airflow.providers.fab.auth_manager.api_fastapi.services.roles import FABAuthManagerRoles
27+
from airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import (
28+
Action,
29+
ActionResource,
30+
PermissionCollectionResponse,
31+
Resource,
32+
)
33+
from airflow.providers.fab.auth_manager.api_fastapi.services.roles import (
34+
FABAuthManagerRoles,
35+
)
2836

2937

3038
@pytest.fixture
@@ -361,3 +369,70 @@ def test_patch_role_not_found(self, get_fab_auth_manager, fab_auth_manager, secu
361369
with pytest.raises(HTTPException) as ex:
362370
FABAuthManagerRoles.patch_role(body=body, name="viewer")
363371
assert ex.value.status_code == 404
372+
373+
def test_get_permissions_success(self, get_fab_auth_manager):
374+
session = MagicMock()
375+
perm_obj = types.SimpleNamespace(
376+
action=types.SimpleNamespace(name="can_read"),
377+
resource=types.SimpleNamespace(name="DAG"),
378+
)
379+
session.scalars.side_effect = [
380+
types.SimpleNamespace(one=lambda: 1),
381+
types.SimpleNamespace(all=lambda: [perm_obj]),
382+
]
383+
fab_auth_manager = MagicMock()
384+
fab_auth_manager.security_manager = MagicMock(session=session)
385+
get_fab_auth_manager.return_value = fab_auth_manager
386+
387+
out = FABAuthManagerRoles.get_permissions(limit=10, offset=0)
388+
assert isinstance(out, PermissionCollectionResponse)
389+
assert out.total_entries == 1
390+
assert len(out.permissions) == 1
391+
assert out.permissions[0] == ActionResource(
392+
action=Action(name="can_read"), resource=Resource(name="DAG")
393+
)
394+
395+
def test_get_permissions_empty(self, get_fab_auth_manager):
396+
session = MagicMock()
397+
session.scalars.side_effect = [
398+
types.SimpleNamespace(one=lambda: 0),
399+
types.SimpleNamespace(all=lambda: []),
400+
]
401+
fab_auth_manager = MagicMock()
402+
fab_auth_manager.security_manager = MagicMock(session=session)
403+
get_fab_auth_manager.return_value = fab_auth_manager
404+
405+
out = FABAuthManagerRoles.get_permissions(limit=10, offset=0)
406+
assert out.total_entries == 0
407+
assert out.permissions == []
408+
409+
def test_get_permissions_with_multiple(self, get_fab_auth_manager):
410+
session = MagicMock()
411+
perm_objs = [
412+
types.SimpleNamespace(
413+
action=types.SimpleNamespace(name="can_read"),
414+
resource=types.SimpleNamespace(name="DAG"),
415+
),
416+
types.SimpleNamespace(
417+
action=types.SimpleNamespace(name="can_edit"),
418+
resource=types.SimpleNamespace(name="DAG"),
419+
),
420+
]
421+
session.scalars.side_effect = [
422+
types.SimpleNamespace(one=lambda: 2),
423+
types.SimpleNamespace(all=lambda: perm_objs),
424+
]
425+
fab_auth_manager = MagicMock()
426+
fab_auth_manager.security_manager = MagicMock(session=session)
427+
get_fab_auth_manager.return_value = fab_auth_manager
428+
429+
out = FABAuthManagerRoles.get_permissions(limit=10, offset=0)
430+
assert isinstance(out, PermissionCollectionResponse)
431+
assert out.total_entries == 2
432+
assert len(out.permissions) == 2
433+
assert out.permissions[0] == ActionResource(
434+
action=Action(name="can_read"), resource=Resource(name="DAG")
435+
)
436+
assert out.permissions[1] == ActionResource(
437+
action=Action(name="can_edit"), resource=Resource(name="DAG")
438+
)

0 commit comments

Comments
 (0)