Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,39 @@ def get_role(cls, name: str) -> RoleResponse:
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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -412,3 +412,143 @@ def test_get_role_validation_404_empty_name(
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")
Loading