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 9b9fc0eec64 Make the FAB roles PATCH endpoint replace permissions, not 
just add them (#71933)
9b9fc0eec64 is described below

commit 9b9fc0eec643a3dadb4951d64eb6ddbd3f1a57fc
Author: Dheeren Mohta <[email protected]>
AuthorDate: Mon Aug 24 22:03:03 2026 +0530

    Make the FAB roles PATCH endpoint replace permissions, not just add them 
(#71933)
    
    PATCH /roles/{name} only ever added permissions found in the request
    body to a role; it never revoked permissions that were on the role but
    missing from the body, so there was no way to remove a permission from
    a role through the stable REST API (#18714). The endpoint already
    required "PUT"-level authorization, and every other PATCH endpoint in
    this API (connections, dags, dag runs, pools, variables) treats a field
    as fully replaced once it is present in the request (via
    model_fields_set) or named in update_mask -- roles just never extended
    that convention to the "actions" list. This also fixes update_mask
    silently letting stray "actions" in the body get applied even when the
    mask didn't request it.
    
    A 2023 attempt (apache/airflow#30193) added a separate POST
    .../actions/revoke endpoint instead of fixing PATCH, which required
    callers to compute their own diff and stalled on bikeshedding over new
    exception classes and HTTP status codes; it went stale and was closed
    unmerged. This instead makes PATCH itself diff against the role's
    current permissions and apply add/remove using the security manager's
    existing single-item methods, so the request body can just state the
    desired end state.
---
 .../fab/auth_manager/api_fastapi/services/roles.py |  67 ++++--
 .../api_fastapi/services/test_roles.py             | 228 ++++++++++++++++-----
 2 files changed, 225 insertions(+), 70 deletions(-)

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 dda6515c265..8d64a650da6 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
@@ -137,31 +137,62 @@ class FABAuthManagerRoles:
                 detail=f"Role with name {name!r} does not exist.",
             )
 
+        # A field is only touched if the client actually sent it (tracked by 
pydantic's
+        # `model_fields_set`, independent of default values). With no 
update_mask that means
+        # every field present in the request body -- consistent with this 
endpoint requiring
+        # "PUT"-level authorization and with how the other PATCH endpoints in 
this API
+        # (connections, dags, dag runs, pools, variables) resolve which fields 
to replace.
+        fields_to_update = set(body.model_fields_set)
         if update_mask:
-            fields_to_update = {f.strip() for f in update_mask.split(",") if 
f.strip()}
-            update_data = RoleResponse.model_validate(existing)
-
-            for field in fields_to_update:
-                if field == "actions":
-                    update_data.permissions = body.permissions
-                elif hasattr(body, field):
-                    setattr(update_data, field, getattr(body, field))
-                else:
+            requested_fields = {f.strip() for f in update_mask.split(",") if 
f.strip()}
+            for field in requested_fields:
+                if field != "actions" and not hasattr(body, field):
                     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 [])
+            # "actions" is the external (JSON) name for the "permissions" 
attribute.
+            normalized_fields = {"permissions" if field == "actions" else 
field for field in requested_fields}
+            fields_to_update &= normalized_fields
 
-        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}])
+        update_data = RoleResponse.model_validate(existing)
+        if "permissions" in fields_to_update:
+            cls._replace_role_permissions(security_manager, existing, 
body.permissions or [])
+            update_data.permissions = body.permissions or []
+        if "name" in fields_to_update:
+            update_data.name = body.name
+
+        if update_data.name != existing.name:
+            security_manager.update_role(role_id=existing.id, 
name=update_data.name)
+        return update_data
 
-        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)
+    @classmethod
+    def _replace_role_permissions(
+        cls,
+        security_manager: FabAirflowSecurityManagerOverride,
+        role: Role,
+        permissions: list[ActionResource],
+    ) -> None:
+        """
+        Make the role's permissions match `permissions` exactly.
+
+        Unlike the additive sync used on role creation, a PATCH that touches 
the permission
+        set must also revoke permissions currently on the role that are absent 
from the
+        request -- otherwise permissions could be added but never removed via 
the API.
+        """
+        target_pairs = {(ar.action.name, ar.resource.name) for ar in 
permissions}
+        cls._check_action_and_resource(security_manager, list(target_pairs))
+
+        current_permissions = {(p.action.name, p.resource.name): p for p in 
role.permissions}
+
+        for action_name, resource_name in target_pairs - 
current_permissions.keys():
+            permission = security_manager.get_permission(
+                action_name, resource_name
+            ) or security_manager.create_permission(action_name, resource_name)
+            security_manager.add_permission_to_role(role, permission)
+
+        for pair in current_permissions.keys() - target_pairs:
+            security_manager.remove_permission_from_role(role, 
current_permissions[pair])
 
     @classmethod
     def get_permissions(cls, *, order_by: str, limit: int, offset: int) -> 
PermissionCollectionResponse:
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 88620b80b23..9bdd0235417 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
@@ -29,6 +29,7 @@ from 
airflow.providers.fab.auth_manager.api_fastapi.datamodels.roles import (
     ActionResource,
     PermissionCollectionResponse,
     Resource,
+    RoleBody,
 )
 from airflow.providers.fab.auth_manager.api_fastapi.services.roles import (
     FABAuthManagerRoles,
@@ -59,6 +60,16 @@ def _make_role_obj(name: str, perms: list[tuple[str, str]]):
     return types.SimpleNamespace(id=1, name=name, permissions=perm_objs)
 
 
+def _make_role_body(name: str, actions: list[tuple[str, str]] | None = None, 
*, include_actions: bool = True):
+    """Build a real `RoleBody` so `model_fields_set` reflects which fields the 
caller supplied."""
+    kwargs: dict = {"name": name}
+    if include_actions:
+        kwargs["actions"] = [
+            ActionResource(action=Action(name=a), resource=Resource(name=r)) 
for (a, r) in (actions or [])
+        ]
+    return RoleBody(**kwargs)
+
+
 class _FakeScalarCount:
     def __init__(self, value: int):
         self._value = value
@@ -263,60 +274,168 @@ class TestRolesService:
 
     # 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")])
+    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
+        security_manager.get_permission.return_value = types.SimpleNamespace(
+            action=types.SimpleNamespace(name="can_edit"), 
resource=types.SimpleNamespace(name="DAG")
+        )
         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"),
-                )
-            ],
-        )
+        body = _make_role_body("editor", [("can_edit", "DAG")])
+
         out = FABAuthManagerRoles.patch_role(body=body, name="viewer")
-        assert out.name == "viewer"
+
+        assert out.name == "editor"
         assert out.permissions
         assert out.permissions[0].action.name == "can_edit"
         assert out.permissions[0].resource.name == "DAG"
+        # The permission set is unchanged, so nothing should be added or 
removed.
+        security_manager.add_permission_to_role.assert_not_called()
+        security_manager.remove_permission_from_role.assert_not_called()
+        security_manager.update_role.assert_called_once_with(role_id=role.id, 
name="editor")
 
-    def test_patch_role_rename_success(self, get_fab_auth_manager, 
fab_auth_manager, security_manager):
-        role = _make_role_obj("viewer", [("can_edit", "DAG")])
+    def test_patch_role_adds_missing_permission(
+        self, get_fab_auth_manager, fab_auth_manager, security_manager
+    ):
+        """Regression test: permissions present in the body but not yet on the 
role are added."""
+        role = _make_role_obj("viewer", [])
         security_manager.find_role.return_value = role
+        new_permission = types.SimpleNamespace(
+            action=types.SimpleNamespace(name="can_edit"), 
resource=types.SimpleNamespace(name="DAG")
+        )
+        security_manager.get_permission.return_value = new_permission
         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"),
-                )
-            ],
-        )
+        body = _make_role_body("viewer", [("can_edit", "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"
+        security_manager.get_permission.assert_called_once_with("can_edit", 
"DAG")
+        security_manager.create_permission.assert_not_called()
+        security_manager.add_permission_to_role.assert_called_once_with(role, 
new_permission)
+        security_manager.remove_permission_from_role.assert_not_called()
 
-    def test_patch_role_with_update_mask(self, get_fab_auth_manager, 
fab_auth_manager, security_manager):
+    def test_patch_role_creates_permission_when_missing_from_db(
+        self, get_fab_auth_manager, fab_auth_manager, security_manager
+    ):
+        """When the (action, resource) pair has no Permission row yet, one is 
created before adding."""
+        role = _make_role_obj("viewer", [])
+        security_manager.find_role.return_value = role
+        security_manager.get_permission.return_value = None
+        created_permission = types.SimpleNamespace(
+            action=types.SimpleNamespace(name="can_edit"), 
resource=types.SimpleNamespace(name="DAG")
+        )
+        security_manager.create_permission.return_value = created_permission
+        fab_auth_manager.security_manager = security_manager
+        get_fab_auth_manager.return_value = fab_auth_manager
+        body = _make_role_body("viewer", [("can_edit", "DAG")])
+
+        FABAuthManagerRoles.patch_role(body=body, name="viewer")
+
+        security_manager.create_permission.assert_called_once_with("can_edit", 
"DAG")
+        security_manager.add_permission_to_role.assert_called_once_with(role, 
created_permission)
+
+    def test_patch_role_removes_permission_absent_from_body(
+        self, get_fab_auth_manager, fab_auth_manager, security_manager
+    ):
+        """The core issue #18714 regression test: a permission missing from 
the PATCH body
+        must be revoked from the role, not silently kept."""
+        role = _make_role_obj("viewer", [("can_read", "DAG"), ("can_edit", 
"DAG")])
+        security_manager.find_role.return_value = role
+        security_manager.get_permission.return_value = types.SimpleNamespace(
+            action=types.SimpleNamespace(name="can_edit"), 
resource=types.SimpleNamespace(name="DAG")
+        )
+        fab_auth_manager.security_manager = security_manager
+        get_fab_auth_manager.return_value = fab_auth_manager
+        body = _make_role_body("viewer", [("can_edit", "DAG")])
+
+        out = FABAuthManagerRoles.patch_role(body=body, name="viewer")
+
+        assert {(p.action.name, p.resource.name) for p in out.permissions} == 
{("can_edit", "DAG")}
+        security_manager.add_permission_to_role.assert_not_called()
+        removed_role, removed_permission = 
security_manager.remove_permission_from_role.call_args.args
+        assert removed_role is role
+        assert (removed_permission.action.name, 
removed_permission.resource.name) == ("can_read", "DAG")
+
+    def test_patch_role_adds_and_removes_permissions_together(
+        self, get_fab_auth_manager, fab_auth_manager, security_manager
+    ):
+        role = _make_role_obj("viewer", [("can_read", "DAG"), ("can_edit", 
"DAG")])
+        security_manager.find_role.return_value = role
+        security_manager.get_permission.return_value = None
+        created_permission = types.SimpleNamespace(
+            action=types.SimpleNamespace(name="can_read"), 
resource=types.SimpleNamespace(name="Connections")
+        )
+        security_manager.create_permission.return_value = created_permission
+        security_manager.get_resource.side_effect = lambda n: (
+            object() if n in {"DAG", "Connections"} else None
+        )
+        fab_auth_manager.security_manager = security_manager
+        get_fab_auth_manager.return_value = fab_auth_manager
+        # can_edit/DAG is kept as-is, can_read/DAG is dropped, 
can_read/Connections is added.
+        body = _make_role_body("viewer", [("can_edit", "DAG"), ("can_read", 
"Connections")])
+
+        out = FABAuthManagerRoles.patch_role(body=body, name="viewer")
+
+        assert {(p.action.name, p.resource.name) for p in out.permissions} == {
+            ("can_edit", "DAG"),
+            ("can_read", "Connections"),
+        }
+        security_manager.add_permission_to_role.assert_called_once_with(role, 
created_permission)
+        removed_role, removed_permission = 
security_manager.remove_permission_from_role.call_args.args
+        assert removed_role is role
+        assert (removed_permission.action.name, 
removed_permission.resource.name) == ("can_read", "DAG")
+
+    def test_patch_role_explicit_empty_actions_removes_all_permissions(
+        self, get_fab_auth_manager, fab_auth_manager, security_manager
+    ):
+        """Sending `"actions": []` is an explicit instruction to clear the 
permission set."""
+        role = _make_role_obj("viewer", [("can_read", "DAG"), ("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 = _make_role_body("viewer", [])
+
+        out = FABAuthManagerRoles.patch_role(body=body, name="viewer")
+
+        assert out.permissions == []
+        assert security_manager.remove_permission_from_role.call_count == 2
+        security_manager.add_permission_to_role.assert_not_called()
+
+    def test_patch_role_without_actions_key_leaves_permissions_untouched(
+        self, get_fab_auth_manager, fab_auth_manager, security_manager
+    ):
+        """A rename-only PATCH that never mentions "actions" must not touch 
permissions at
+        all -- omitting the field is not the same as sending an empty list."""
         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"),
-                )
-            ],
+        body = _make_role_body("editor", include_actions=False)
+
+        out = FABAuthManagerRoles.patch_role(body=body, name="viewer")
+
+        assert out.name == "editor"
+        assert [(p.action.name, p.resource.name) for p in out.permissions] == 
[("can_read", "DAG")]
+        security_manager.add_permission_to_role.assert_not_called()
+        security_manager.remove_permission_from_role.assert_not_called()
+        security_manager.get_permission.assert_not_called()
+
+    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
+        security_manager.get_permission.return_value = types.SimpleNamespace(
+            action=types.SimpleNamespace(name="can_edit"), 
resource=types.SimpleNamespace(name="DAG")
         )
+        fab_auth_manager.security_manager = security_manager
+        get_fab_auth_manager.return_value = fab_auth_manager
+        # "name" differs from the role's current name but is excluded by the 
mask.
+        body = _make_role_body("viewer1", [("can_edit", "DAG")])
+
         out = FABAuthManagerRoles.patch_role(
             body=body,
             name="viewer",
@@ -326,23 +445,19 @@ class TestRolesService:
         assert out.permissions
         assert out.permissions[0].action.name == "can_edit"
         assert out.permissions[0].resource.name == "DAG"
+        security_manager.update_role.assert_not_called()
 
-    def test_patch_role_rename_with_update_mask(
+    def test_patch_role_rename_with_update_mask_leaves_permissions_untouched(
         self, get_fab_auth_manager, fab_auth_manager, security_manager
     ):
+        """update_mask=name must not apply the body's `actions`, even though 
the body
+        includes a different permission set (the pre-fix code ignored the mask 
here)."""
         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"),
-                )
-            ],
-        )
+        body = _make_role_body("viewer1", [("can_edit", "DAG")])
+
         out = FABAuthManagerRoles.patch_role(
             body=body,
             name="viewer",
@@ -352,20 +467,29 @@ class TestRolesService:
         assert out.permissions
         assert out.permissions[0].action.name == "can_read"
         assert out.permissions[0].resource.name == "DAG"
+        security_manager.add_permission_to_role.assert_not_called()
+        security_manager.remove_permission_from_role.assert_not_called()
+        security_manager.get_permission.assert_not_called()
+
+    def test_patch_role_unknown_update_mask_field(
+        self, get_fab_auth_manager, fab_auth_manager, security_manager
+    ):
+        role = _make_role_obj("viewer", [])
+        security_manager.find_role.return_value = role
+        fab_auth_manager.security_manager = security_manager
+        get_fab_auth_manager.return_value = fab_auth_manager
+        body = _make_role_body("viewer", [])
+
+        with pytest.raises(HTTPException) as ex:
+            FABAuthManagerRoles.patch_role(body=body, name="viewer", 
update_mask="unknown_field")
+        assert ex.value.status_code == 400
+        assert ex.value.detail == "'unknown_field' in update_mask is unknown"
 
     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"),
-                )
-            ],
-        )
+        body = _make_role_body("viewer", [("can_edit", "DAG")])
         with pytest.raises(HTTPException) as ex:
             FABAuthManagerRoles.patch_role(body=body, name="viewer")
         assert ex.value.status_code == 404

Reply via email to