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 e288b73c85b Check all names before airflow roles import writes 
anything (#74711)
e288b73c85b is described below

commit e288b73c85b90356d311ca71986b80ebcf9689e2
Author: CyberNative AI LLC <[email protected]>
AuthorDate: Wed Oct 14 03:59:07 2026 -0700

    Check all names before airflow roles import writes anything (#74711)
---
 .../fab/auth_manager/cli_commands/role_command.py  | 26 ++++++++
 .../auth_manager/cli_commands/test_role_command.py | 69 ++++++++++++++++++++++
 2 files changed, 95 insertions(+)

diff --git 
a/providers/fab/src/airflow/providers/fab/auth_manager/cli_commands/role_command.py
 
b/providers/fab/src/airflow/providers/fab/auth_manager/cli_commands/role_command.py
index 5f065a3bd0f..130fa123827 100644
--- 
a/providers/fab/src/airflow/providers/fab/auth_manager/cli_commands/role_command.py
+++ 
b/providers/fab/src/airflow/providers/fab/auth_manager/cli_commands/role_command.py
@@ -204,6 +204,32 @@ def roles_import(args):
     with get_application_builder() as appbuilder:
         existing_roles = [role.name for role in appbuilder.sm.get_all_roles()]
         roles_to_import = [role_dict for role_dict in role_list if 
role_dict["name"] not in existing_roles]
+        # Each permission is committed as it is added, so check every name 
before the first write;
+        # otherwise a failure leaves a partial role that a later import skips 
as already existing.
+        permission_entries = [
+            role_dict
+            for role_dict in roles_to_import
+            if role_dict.get("action") and role_dict.get("resource")
+        ]
+        unknown_resources = [
+            name
+            for name in dict.fromkeys(role_dict["resource"] for role_dict in 
permission_entries)
+            if not appbuilder.sm.get_resource(name)
+        ]
+        unknown_actions = [
+            name
+            for name in dict.fromkeys(
+                action for role_dict in permission_entries for action in 
role_dict["action"].split(",")
+            )
+            if not appbuilder.sm.get_action(name)
+        ]
+        if unknown_resources or unknown_actions:
+            for name in unknown_resources:
+                print(f"Resource named '{name}' does not exist")
+            for name in unknown_actions:
+                print(f"Action named '{name}' does not exist")
+            print("No roles were imported")
+            exit(1)
         for role_dict in roles_to_import:
             # ``roles_export`` emits one entry per (role, resource) pair, so a 
role name
             # repeats across entries; ``add_role`` returns the existing role 
after the first.
diff --git 
a/providers/fab/tests/unit/fab/auth_manager/cli_commands/test_role_command.py 
b/providers/fab/tests/unit/fab/auth_manager/cli_commands/test_role_command.py
index f79ce5eba45..4869a6944da 100644
--- 
a/providers/fab/tests/unit/fab/auth_manager/cli_commands/test_role_command.py
+++ 
b/providers/fab/tests/unit/fab/auth_manager/cli_commands/test_role_command.py
@@ -248,3 +248,72 @@ class TestCliRoles:
             (permissions.RESOURCE_POOL, permissions.ACTION_CAN_EDIT),
             (permissions.RESOURCE_POOL, permissions.ACTION_CAN_READ),
         }
+
+    @pytest.mark.parametrize(
+        ("roles_list", "messages"),
+        [
+            pytest.param(
+                [
+                    {"name": "FakeTeamA", "resource": "Pools", "action": 
"can_edit,can_read"},
+                    {"name": "FakeTeamB", "resource": "NonExistentResource", 
"action": "can_read"},
+                ],
+                ["Resource named 'NonExistentResource' does not exist"],
+                id="unknown-resource",
+            ),
+            pytest.param(
+                [
+                    {"name": "FakeTeamA", "resource": "Pools", "action": 
"can_edit,can_read"},
+                    {"name": "FakeTeamB", "resource": "Pools", "action": 
"can_read,can_nonexistent"},
+                ],
+                ["Action named 'can_nonexistent' does not exist"],
+                id="unknown-action",
+            ),
+            pytest.param(
+                [
+                    {"name": "FakeTeamA", "resource": "Pools", "action": 
"can_edit,can_read"},
+                    {"name": "FakeTeamB", "resource": "NonExistentResource", 
"action": "can_read"},
+                    {"name": "FakeTeamB", "resource": "Pools", "action": 
"can_nonexistent"},
+                    {"name": "FakeTeamC", "resource": "NonExistentResource", 
"action": "can_nonexistent"},
+                ],
+                [
+                    "Resource named 'NonExistentResource' does not exist",
+                    "Action named 'can_nonexistent' does not exist",
+                ],
+                id="several-unknown-names",
+            ),
+        ],
+    )
+    def test_cli_import_roles_with_unknown_names_changes_nothing(self, 
tmp_path, roles_list, messages):
+        fn = tmp_path / "import_roles.json"
+        fn.write_text(json.dumps(roles_list))
+
+        with redirect_stdout(StringIO()) as stdout_io, 
pytest.raises(SystemExit) as exc_info:
+            role_command.roles_import(self.parser.parse_args(["roles", 
"import", str(fn)]))
+
+        assert exc_info.value.code == 1
+        stdout = stdout_io.getvalue()
+        for message in messages:
+            assert stdout.count(message) == 1
+        assert self.appbuilder.sm.find_role("FakeTeamA") is None
+        assert self.appbuilder.sm.find_role("FakeTeamB") is None
+        assert self.appbuilder.sm.find_role("FakeTeamC") is None
+
+    def 
test_cli_import_roles_skips_entries_of_existing_roles_before_checking_names(self,
 tmp_path):
+        self.appbuilder.sm.add_role("FakeTeamA")
+        fn = tmp_path / "import_roles.json"
+        roles_list = [
+            {"name": "FakeTeamA", "resource": "NonExistentResource", "action": 
"can_nonexistent"},
+            {"name": "FakeTeamB", "resource": "Pools", "action": "can_read"},
+        ]
+        fn.write_text(json.dumps(roles_list))
+        role_command.roles_import(self.parser.parse_args(["roles", "import", 
str(fn)]))
+
+        fakeTeamA: Role = self.appbuilder.sm.find_role("FakeTeamA")
+        fakeTeamB: Role = self.appbuilder.sm.find_role("FakeTeamB")
+
+        assert fakeTeamA is not None
+        assert fakeTeamB is not None
+        assert len(fakeTeamA.permissions) == 0
+        assert {
+            (permission.resource.name, permission.action.name) for permission 
in fakeTeamB.permissions
+        } == {(permissions.RESOURCE_POOL, permissions.ACTION_CAN_READ)}

Reply via email to