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 6f2820c7993 Fix Keycloak multi-team permission repair (#71614)
6f2820c7993 is described below

commit 6f2820c79939e47e0ef255fa116728448b3af9d2
Author: Vincent Hsiao <[email protected]>
AuthorDate: Fri Aug 14 22:41:04 2026 +0800

    Fix Keycloak multi-team permission repair (#71614)
---
 providers/keycloak/docs/changelog.rst              |  8 +--
 .../keycloak/auth_manager/cli/commands.py          | 41 ++++++++++++-
 .../keycloak/auth_manager/cli/test_commands.py     | 71 ++++++++++++++++++++++
 3 files changed, 115 insertions(+), 5 deletions(-)

diff --git a/providers/keycloak/docs/changelog.rst 
b/providers/keycloak/docs/changelog.rst
index 64f75516305..76b5a6bf2c1 100644
--- a/providers/keycloak/docs/changelog.rst
+++ b/providers/keycloak/docs/changelog.rst
@@ -27,10 +27,10 @@ Changelog
 
 .. note::
     Upgrading the provider does not update existing Keycloak permissions. For 
each existing team,
-    run ``airflow keycloak-auth-manager create-team <team>`` again to update 
the team ReadOnly
-    permission. For non-team installations, run
-    ``airflow keycloak-auth-manager create-permissions`` again without 
``--teams`` to update the
-    global Admin permission.
+    run ``airflow keycloak-auth-manager create-team <team>`` again to update 
the team-specific
+    permissions and repair the global ``ReadOnly`` and ``Admin`` permissions. 
For non-team
+    installations, run ``airflow keycloak-auth-manager create-permissions`` 
again without
+    ``--teams`` to update the global ``ReadOnly`` and ``Admin`` permissions.
 
     Manually added policies attached to these permissions will also be 
evaluated under the
     ``AFFIRMATIVE`` strategy after the update.
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/commands.py
 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/commands.py
index 5992e64e425..4daeef9b008 100644
--- 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/commands.py
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/commands.py
@@ -306,6 +306,40 @@ def _attach_default_role_permissions(
         )
 
 
+def _update_read_only_permission_resources(
+    client: KeycloakAdmin, client_uuid: str, *, _dry_run: bool = False
+) -> None:
+    if _dry_run:
+        print("Would update permission 'ReadOnly' with unscoped readable 
resources.")
+        return
+
+    permissions = client.get_client_authz_permissions(client_uuid)
+    match = next((perm for perm in permissions if perm.get("name") == 
"ReadOnly"), None)
+    if not match:
+        return
+
+    permission_id = match["id"]
+    scopes = client.get_client_authz_scopes(client_uuid)
+    resources = client.get_client_authz_resources(client_uuid)
+    scope_ids = [s["id"] for s in scopes if s["name"] in ["GET", "MENU", 
"LIST"]]
+    resource_names = TEAM_SCOPED_RESOURCE_NAMES | GLOBAL_SCOPED_RESOURCE_NAMES
+    resource_ids = [r["_id"] for r in resources if r["name"] in resource_names]
+    policy_ids = _get_permission_policy_ids(client, client_uuid, permission_id)
+    payload = {
+        "id": permission_id,
+        "name": "ReadOnly",
+        "type": "scope",
+        "logic": "POSITIVE",
+        "decisionStrategy": "AFFIRMATIVE",
+        "scopes": scope_ids,
+        "resources": resource_ids,
+        "policies": policy_ids,
+    }
+    client.update_client_authz_scope_permission(
+        payload=payload, client_id=client_uuid, scope_id=permission_id
+    )
+
+
 def _preview_scopes(*args, **kwargs):
     """Preview scopes that would be created."""
     scopes = _get_scopes_to_create()
@@ -702,7 +736,11 @@ def _update_admin_permission_resources(
         or r["name"] in GLOBAL_SCOPED_RESOURCE_NAMES
     ]
 
-    policy_ids = _get_permission_policy_ids(client, client_uuid, permission_id)
+    existing_policy_ids = _get_permission_policy_ids(client, client_uuid, 
permission_id)
+    super_admin_policy_id = _get_policy_id(client, client_uuid, 
_role_policy_name(SUPER_ADMIN_ROLE_NAME))
+    policy_ids = [policy_id for policy_id in existing_policy_ids if policy_id 
== super_admin_policy_id]
+    if super_admin_policy_id and super_admin_policy_id not in policy_ids:
+        policy_ids.append(super_admin_policy_id)
     payload = {
         "id": permission_id,
         "name": "Admin",
@@ -736,6 +774,7 @@ def create_team_command(args):
     _attach_team_permissions(client, client_uuid, team, _dry_run=args.dry_run)
     _attach_team_menu_permissions(client, client_uuid, team, 
_dry_run=args.dry_run)
     _attach_superadmin_permissions(client, client_uuid, team, 
_dry_run=args.dry_run)
+    _update_read_only_permission_resources(client, client_uuid, 
_dry_run=args.dry_run)
     _update_admin_permission_resources(client, client_uuid, 
_dry_run=args.dry_run)
 
 
diff --git 
a/providers/keycloak/tests/unit/keycloak/auth_manager/cli/test_commands.py 
b/providers/keycloak/tests/unit/keycloak/auth_manager/cli/test_commands.py
index f4b9c714fca..bb31ebbbc99 100644
--- a/providers/keycloak/tests/unit/keycloak/auth_manager/cli/test_commands.py
+++ b/providers/keycloak/tests/unit/keycloak/auth_manager/cli/test_commands.py
@@ -29,6 +29,8 @@ from airflow.providers.keycloak.auth_manager.cli.commands 
import (
     TEAM_SCOPED_RESOURCE_NAMES,
     _get_extended_resource_methods,
     _get_resource_methods,
+    _update_admin_permission_resources,
+    _update_read_only_permission_resources,
     add_user_to_team_command,
     create_all_command,
     create_permissions_command,
@@ -438,6 +440,72 @@ class TestCommands:
             skip_exists=True,
         )
 
+    
@patch("airflow.providers.keycloak.auth_manager.cli.commands._get_permission_policy_ids")
+    def test_update_read_only_permission_resources_excludes_team_resources(
+        self, mock_get_permission_policy_ids
+    ):
+        client = Mock()
+        client.get_client_authz_permissions.return_value = [{"id": 
"readonly-id", "name": "ReadOnly"}]
+        client.get_client_authz_scopes.return_value = [
+            {"id": "scope-get", "name": "GET"},
+            {"id": "scope-list", "name": "LIST"},
+            {"id": "scope-menu", "name": "MENU"},
+            {"id": "scope-put", "name": "PUT"},
+        ]
+        client.get_client_authz_resources.return_value = [
+            {"_id": "dag-global", "name": "Dag"},
+            {"_id": "dag-team-a", "name": "Dag:team-a"},
+            {"_id": "variable-global", "name": "Variable"},
+            {"_id": "variable-team-a", "name": "Variable:team-a"},
+            {"_id": "asset-global", "name": "Asset"},
+        ]
+        mock_get_permission_policy_ids.return_value = ["viewer-policy", 
"admin-policy"]
+
+        _update_read_only_permission_resources(client, "test-id")
+
+        client.update_client_authz_scope_permission.assert_called_once_with(
+            client_id="test-id",
+            scope_id="readonly-id",
+            payload={
+                "id": "readonly-id",
+                "name": "ReadOnly",
+                "type": "scope",
+                "logic": "POSITIVE",
+                "decisionStrategy": "AFFIRMATIVE",
+                "scopes": ["scope-get", "scope-list", "scope-menu"],
+                "resources": ["dag-global", "variable-global", "asset-global"],
+                "policies": ["viewer-policy", "admin-policy"],
+            },
+        )
+
+    
@patch("airflow.providers.keycloak.auth_manager.cli.commands._get_policy_id")
+    
@patch("airflow.providers.keycloak.auth_manager.cli.commands._get_permission_policy_ids")
+    def test_update_admin_permission_resources_removes_admin_role_policy(
+        self, mock_get_permission_policy_ids, mock_get_policy_id
+    ):
+        client = Mock()
+        client.get_client_authz_permissions.return_value = [{"id": "admin-id", 
"name": "Admin"}]
+        client.get_client_authz_scopes.return_value = [
+            {"id": "scope-get", "name": "GET"},
+            {"id": "scope-list", "name": "LIST"},
+            {"id": "scope-put", "name": "PUT"},
+        ]
+        client.get_client_authz_resources.return_value = [
+            {"_id": "dag-global", "name": "Dag"},
+            {"_id": "dag-team-a", "name": "Dag:team-a"},
+            {"_id": "dag-team-b", "name": "Dag:team-b"},
+            {"_id": "asset-global", "name": "Asset"},
+        ]
+        mock_get_permission_policy_ids.return_value = ["admin-policy", 
"superadmin-policy"]
+        mock_get_policy_id.return_value = "superadmin-policy"
+
+        _update_admin_permission_resources(client, "test-id")
+
+        client.update_client_authz_scope_permission.assert_called_once()
+        payload = 
client.update_client_authz_scope_permission.call_args.kwargs["payload"]
+        assert payload["policies"] == ["superadmin-policy"]
+        assert payload["resources"] == ["dag-team-a", "dag-team-b", 
"asset-global"]
+
     
@patch("airflow.providers.keycloak.auth_manager.cli.commands._attach_policy_to_resource_permission")
     
@patch("airflow.providers.keycloak.auth_manager.cli.commands._attach_policy_to_scope_permission")
     
@patch("airflow.providers.keycloak.auth_manager.cli.commands._ensure_role_policy")
@@ -512,6 +580,7 @@ class TestCommands:
         )
 
     
@patch("airflow.providers.keycloak.auth_manager.cli.commands._update_admin_permission_resources")
+    
@patch("airflow.providers.keycloak.auth_manager.cli.commands._update_read_only_permission_resources")
     
@patch("airflow.providers.keycloak.auth_manager.cli.commands._ensure_scope_permission")
     
@patch("airflow.providers.keycloak.auth_manager.cli.commands._attach_policy_to_resource_permission")
     
@patch("airflow.providers.keycloak.auth_manager.cli.commands._attach_policy_to_scope_permission")
@@ -532,6 +601,7 @@ class TestCommands:
         mock_attach_policy,
         mock_attach_resource_policy,
         mock_ensure_scope_permission,
+        mock_update_read_only_permission_resources,
         mock_update_admin_permission_resources,
     ):
         client = Mock()
@@ -568,6 +638,7 @@ class TestCommands:
         mock_create_permissions.assert_called_once_with(
             client, "test-id", teams=["team-a"], include_global_admin=False, 
_dry_run=False
         )
+        
mock_update_read_only_permission_resources.assert_called_once_with(client, 
"test-id", _dry_run=False)
         mock_update_admin_permission_resources.assert_called_once_with(client, 
"test-id", _dry_run=False)
         mock_ensure_group_policy.assert_called_once_with(client, "test-id", 
"team-a", _dry_run=False)
         assert mock_ensure_aggregate_policy.call_count == 4

Reply via email to