This is an automated email from the ASF dual-hosted git repository.

potiuk 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 95658615b68 Keycloak CLI: add dry run functionality (#59134)
95658615b68 is described below

commit 95658615b687a310b29558609ed5dadf2257448b
Author: ecodina <[email protected]>
AuthorDate: Tue Dec 9 01:17:59 2025 +0100

    Keycloak CLI: add dry run functionality (#59134)
    
    * refactor keycloak cli to seperate get and create methods
    
    * implement dry run command
    
    * add command to docs
    
    * missing print
    
    * use decorators to improve code readibility
    
    * refactor: update dry_run parameter to _dry_run in commands and tests
    
    * test: add unit tests for dry run message wrapping and preview 
functionality
---
 .../docs/auth-manager/manage/permissions.rst       |   4 +
 .../keycloak/auth_manager/cli/commands.py          | 227 ++++++++++++-----
 .../keycloak/auth_manager/cli/definition.py        |  13 +-
 .../providers/keycloak/auth_manager/cli/utils.py   |  81 ++++++
 .../keycloak/auth_manager/cli/test_commands.py     | 278 ++++++++++++++-------
 .../unit/keycloak/auth_manager/cli/test_utils.py   |  61 +++++
 6 files changed, 502 insertions(+), 162 deletions(-)

diff --git a/providers/keycloak/docs/auth-manager/manage/permissions.rst 
b/providers/keycloak/docs/auth-manager/manage/permissions.rst
index 4c6f96641f6..67c88f494c2 100644
--- a/providers/keycloak/docs/auth-manager/manage/permissions.rst
+++ b/providers/keycloak/docs/auth-manager/manage/permissions.rst
@@ -41,6 +41,10 @@ CLI commands take the following parameters:
 * ``--user-realm``: Keycloak user realm
 * ``--client-id``: Keycloak client id (default: admin-cli)
 
+They also take the following optional parameters:
+
+* ``--dry-run``: If set, the command will check the connection to Keycloak and 
print the actions that would be performed, without actually executing them.
+
 Please check the `Keycloak auth manager CLI </cli-refs.html>`_ documentation 
for more information about accepted parameters.
 
 One-go creation of permissions
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 9ab2c9d8792..c7406baec09 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
@@ -30,6 +30,7 @@ except ImportError:
     from airflow.api_fastapi.auth.managers.base_auth_manager import 
ResourceMethod as ExtendedResourceMethod
 from airflow.api_fastapi.common.types import MenuItem
 from airflow.configuration import conf
+from airflow.providers.keycloak.auth_manager.cli.utils import 
dry_run_message_wrap, dry_run_preview
 from airflow.providers.keycloak.auth_manager.constants import (
     CONF_CLIENT_ID_KEY,
     CONF_REALM_KEY,
@@ -45,44 +46,48 @@ log = logging.getLogger(__name__)
 
 @cli_utils.action_cli
 @providers_configuration_loaded
+@dry_run_message_wrap
 def create_scopes_command(args):
     """Create Keycloak auth manager scopes in Keycloak."""
     client = _get_client(args)
     client_uuid = _get_client_uuid(args)
 
-    _create_scopes(client, client_uuid)
+    _create_scopes(client, client_uuid, _dry_run=args.dry_run)
 
 
 @cli_utils.action_cli
 @providers_configuration_loaded
+@dry_run_message_wrap
 def create_resources_command(args):
     """Create Keycloak auth manager resources in Keycloak."""
     client = _get_client(args)
     client_uuid = _get_client_uuid(args)
 
-    _create_resources(client, client_uuid)
+    _create_resources(client, client_uuid, _dry_run=args.dry_run)
 
 
 @cli_utils.action_cli
 @providers_configuration_loaded
+@dry_run_message_wrap
 def create_permissions_command(args):
     """Create Keycloak auth manager permissions in Keycloak."""
     client = _get_client(args)
     client_uuid = _get_client_uuid(args)
 
-    _create_permissions(client, client_uuid)
+    _create_permissions(client, client_uuid, _dry_run=args.dry_run)
 
 
 @cli_utils.action_cli
 @providers_configuration_loaded
+@dry_run_message_wrap
 def create_all_command(args):
     """Create all Keycloak auth manager entities in Keycloak."""
     client = _get_client(args)
     client_uuid = _get_client_uuid(args)
 
-    _create_scopes(client, client_uuid)
-    _create_resources(client, client_uuid)
-    _create_permissions(client, client_uuid)
+    _create_scopes(client, client_uuid, _dry_run=args.dry_run)
+    _create_resources(client, client_uuid, _dry_run=args.dry_run)
+    _create_permissions(client, client_uuid, _dry_run=args.dry_run)
 
 
 def _get_client(args):
@@ -112,41 +117,97 @@ def _get_client_uuid(args):
     return matches[0]["id"]
 
 
-def _create_scopes(client: KeycloakAdmin, client_uuid: str):
+def _get_scopes_to_create() -> list[dict]:
+    """Get the list of scopes to be created."""
     scopes = [{"name": method} for method in get_args(ResourceMethod)]
     scopes.extend([{"name": "MENU"}, {"name": "LIST"}])
+    return scopes
+
+
+def _preview_scopes(*args, **kwargs):
+    """Preview scopes that would be created."""
+    scopes = _get_scopes_to_create()
+    print("Scopes to be created:")
+    for scope in scopes:
+        print(f"  - {scope['name']}")
+    print()
+
+
+@dry_run_preview(_preview_scopes)
+def _create_scopes(client: KeycloakAdmin, client_uuid: str, *, _dry_run: bool 
= False):
+    """Create Keycloak scopes."""
+    scopes = _get_scopes_to_create()
+
     for scope in scopes:
         client.create_client_authz_scopes(client_id=client_uuid, payload=scope)
 
     print("Scopes created successfully.")
 
 
-def _create_resources(client: KeycloakAdmin, client_uuid: str):
+def _get_resources_to_create(
+    client: KeycloakAdmin,
+    client_uuid: str,
+) -> tuple[list[tuple[str, list[dict]]], list[tuple[str, list[dict]]]]:
+    """
+    Get the list of resources to be created.
+
+    Returns a tuple of (standard_resources, menu_resources).
+    Each is a list of tuples (resource_name, scopes_list).
+    """
     all_scopes = client.get_client_authz_scopes(client_uuid)
+
     scopes = [
         {"id": scope["id"], "name": scope["name"]}
         for scope in all_scopes
         if scope["name"] in ["GET", "POST", "PUT", "DELETE", "LIST"]
     ]
+    menu_scopes = [
+        {"id": scope["id"], "name": scope["name"]} for scope in all_scopes if 
scope["name"] == "MENU"
+    ]
+
+    standard_resources = [(resource.value, scopes) for resource in 
KeycloakResource]
+    menu_resources = [(item.value, menu_scopes) for item in MenuItem]
 
-    for resource in KeycloakResource:
+    return standard_resources, menu_resources
+
+
+def _preview_resources(client: KeycloakAdmin, client_uuid: str):
+    """Preview resources that would be created."""
+    standard_resources, menu_resources = _get_resources_to_create(client, 
client_uuid)
+
+    print("Resources to be created:")
+    if standard_resources:
+        for resource_name, resource_scopes in standard_resources:
+            actual_scope_names = ", ".join([s["name"] for s in 
resource_scopes])
+            print(f"  - {resource_name} (scopes: {actual_scope_names})")
+    print("\nMenu item resources to be created:")
+    for resource_name, resource_scopes in menu_resources:
+        actual_scope_names = ", ".join([s["name"] for s in resource_scopes])
+        print(f"  - {resource_name} (scopes: {actual_scope_names})")
+    print()
+
+
+@dry_run_preview(_preview_resources)
+def _create_resources(client: KeycloakAdmin, client_uuid: str, *, _dry_run: 
bool = False):
+    """Create Keycloak resources."""
+    standard_resources, menu_resources = _get_resources_to_create(client, 
client_uuid)
+
+    for resource_name, scopes in standard_resources:
         client.create_client_authz_resource(
             client_id=client_uuid,
             payload={
-                "name": resource.value,
+                "name": resource_name,
                 "scopes": scopes,
             },
             skip_exists=True,
         )
 
     # Create menu item resources
-    scopes = [{"id": scope["id"], "name": scope["name"]} for scope in 
all_scopes if scope["name"] == "MENU"]
-
-    for item in MenuItem:
+    for resource_name, scopes in menu_resources:
         client.create_client_authz_resource(
             client_id=client_uuid,
             payload={
-                "name": item.value,
+                "name": resource_name,
                 "scopes": scopes,
             },
             skip_exists=True,
@@ -155,63 +216,102 @@ def _create_resources(client: KeycloakAdmin, 
client_uuid: str):
     print("Resources created successfully.")
 
 
-def _create_permissions(client: KeycloakAdmin, client_uuid: str):
-    _create_read_only_permission(client, client_uuid)
-    _create_admin_permission(client, client_uuid)
-    _create_user_permission(client, client_uuid)
-    _create_op_permission(client, client_uuid)
+def _get_permissions_to_create(client: KeycloakAdmin, client_uuid: str) -> 
list[dict]:
+    """
+    Get the actual permissions to be created with filtered scopes/resources.
+
+    Returns a list of permission descriptors with actual filtered data.
+    """
+    perm_configs = [
+        {
+            "name": "ReadOnly",
+            "type": "scope-based",
+            "scope_names": ["GET", "MENU", "LIST"],
+        },
+        {
+            "name": "Admin",
+            "type": "scope-based",
+            "scope_names": list(get_args(ExtendedResourceMethod)) + ["LIST"],
+        },
+        {
+            "name": "User",
+            "type": "resource-based",
+            "resources": [KeycloakResource.DAG.value, 
KeycloakResource.ASSET.value],
+        },
+        {
+            "name": "Op",
+            "type": "resource-based",
+            "resources": [
+                KeycloakResource.CONNECTION.value,
+                KeycloakResource.POOL.value,
+                KeycloakResource.VARIABLE.value,
+                KeycloakResource.BACKFILL.value,
+            ],
+        },
+    ]
 
-    print("Permissions created successfully.")
+    all_scopes = client.get_client_authz_scopes(client_uuid)
+    all_resources = client.get_client_authz_resources(client_uuid)
 
+    result = []
+
+    for config in perm_configs:
+        perm = {"name": config["name"], "type": config["type"]}
+        if config["type"] == "scope-based":
+            # Filter to get actual scope IDs that exist and match
+            filtered_scope_ids = [s["id"] for s in all_scopes if s["name"] in 
config["scope_names"]]
+            filtered_scope_names = [s["name"] for s in all_scopes if s["name"] 
in config["scope_names"]]
+            perm["scope_ids"] = filtered_scope_ids
+            perm["scope_names"] = filtered_scope_names
+        else:  # resource-based
+            # Filter to get actual resource IDs that exist and match
+            filtered_resource_ids = [r["_id"] for r in all_resources if 
r["name"] in config["resources"]]
+            filtered_resource_names = [r["name"] for r in all_resources if 
r["name"] in config["resources"]]
+            perm["resource_ids"] = filtered_resource_ids
+            perm["resource_names"] = filtered_resource_names
+        result.append(perm)
+
+    return result
+
+
+def _preview_permissions(client: KeycloakAdmin, client_uuid: str):
+    """Preview permissions that would be created."""
+    permissions = _get_permissions_to_create(client, client_uuid)
+
+    print("Permissions to be created:")
+    for perm in permissions:
+        if perm["type"] == "scope-based":
+            scope_names = ", ".join(perm["scope_names"])
+            print(f"  - {perm['name']} (type: scope-based, scopes: 
{scope_names})")
+        else:  # resource-based
+            resource_names = ", ".join(perm["resource_names"])
+            print(f"  - {perm['name']} (type: resource-based, resources: 
{resource_names})")
+    print()
+
+
+@dry_run_preview(_preview_permissions)
+def _create_permissions(client: KeycloakAdmin, client_uuid: str, *, _dry_run: 
bool = False):
+    """Create Keycloak permissions."""
+    permissions = _get_permissions_to_create(client, client_uuid)
+
+    for perm in permissions:
+        if perm["type"] == "scope-based":
+            _create_scope_based_permission(client, client_uuid, perm["name"], 
perm["scope_ids"])
+        else:  # resource-based
+            _create_resource_based_permission(client, client_uuid, 
perm["name"], perm["resource_ids"])
 
-def _create_read_only_permission(client: KeycloakAdmin, client_uuid: str):
-    all_scopes = client.get_client_authz_scopes(client_uuid)
-    scopes = [scope["id"] for scope in all_scopes if scope["name"] in ["GET", 
"MENU", "LIST"]]
-    payload = {
-        "name": "ReadOnly",
-        "type": "scope",
-        "logic": "POSITIVE",
-        "decisionStrategy": "UNANIMOUS",
-        "scopes": scopes,
-    }
-    _create_permission(client, client_uuid, payload)
+    print("Permissions created successfully.")
 
 
-def _create_admin_permission(client: KeycloakAdmin, client_uuid: str):
-    all_scopes = client.get_client_authz_scopes(client_uuid)
-    scope_names = get_args(ExtendedResourceMethod) + ("LIST",)
-    scopes = [scope["id"] for scope in all_scopes if scope["name"] in 
scope_names]
+def _create_scope_based_permission(client: KeycloakAdmin, client_uuid: str, 
name: str, scope_ids: list[str]):
     payload = {
-        "name": "Admin",
+        "name": name,
         "type": "scope",
         "logic": "POSITIVE",
         "decisionStrategy": "UNANIMOUS",
-        "scopes": scopes,
+        "scopes": scope_ids,
     }
-    _create_permission(client, client_uuid, payload)
 
-
-def _create_user_permission(client: KeycloakAdmin, client_uuid: str):
-    _create_resource_based_permission(
-        client, client_uuid, "User", [KeycloakResource.DAG.value, 
KeycloakResource.ASSET.value]
-    )
-
-
-def _create_op_permission(client: KeycloakAdmin, client_uuid: str):
-    _create_resource_based_permission(
-        client,
-        client_uuid,
-        "Op",
-        [
-            KeycloakResource.CONNECTION.value,
-            KeycloakResource.POOL.value,
-            KeycloakResource.VARIABLE.value,
-            KeycloakResource.BACKFILL.value,
-        ],
-    )
-
-
-def _create_permission(client: KeycloakAdmin, client_uuid: str, payload: dict):
     try:
         client.create_client_authz_scope_permission(
             client_id=client_uuid,
@@ -225,17 +325,14 @@ def _create_permission(client: KeycloakAdmin, 
client_uuid: str, payload: dict):
 
 
 def _create_resource_based_permission(
-    client: KeycloakAdmin, client_uuid: str, name: str, allowed_resources: 
list[str]
+    client: KeycloakAdmin, client_uuid: str, name: str, resource_ids: list[str]
 ):
-    all_resources = client.get_client_authz_resources(client_uuid)
-    resources = [resource["_id"] for resource in all_resources if 
resource["name"] in allowed_resources]
-
     payload = {
         "name": name,
         "type": "scope",
         "logic": "POSITIVE",
         "decisionStrategy": "UNANIMOUS",
-        "resources": resources,
+        "resources": resource_ids,
     }
     client.create_client_authz_resource_based_permission(
         client_id=client_uuid,
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/definition.py
 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/definition.py
index 4d06c412ce5..805f9c0c8a9 100644
--- 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/definition.py
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/definition.py
@@ -39,6 +39,11 @@ ARG_USER_REALM = Arg(
     ("--user-realm",), help="Realm name where the user used to create 
resources is", default="master"
 )
 ARG_CLIENT_ID = Arg(("--client-id",), help="ID of the client used to create 
resources", default="admin-cli")
+ARG_DRY_RUN = Arg(
+    ("--dry-run",),
+    help="Perform a dry run without creating any resources",
+    action="store_true",
+)
 
 
 ################
@@ -50,7 +55,7 @@ KEYCLOAK_AUTH_MANAGER_COMMANDS = (
         name="create-scopes",
         help="Create scopes in Keycloak",
         
func=lazy_load_command("airflow.providers.keycloak.auth_manager.cli.commands.create_scopes_command"),
-        args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID),
+        args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID, 
ARG_DRY_RUN),
     ),
     ActionCommand(
         name="create-resources",
@@ -58,7 +63,7 @@ KEYCLOAK_AUTH_MANAGER_COMMANDS = (
         func=lazy_load_command(
             
"airflow.providers.keycloak.auth_manager.cli.commands.create_resources_command"
         ),
-        args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID),
+        args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID, 
ARG_DRY_RUN),
     ),
     ActionCommand(
         name="create-permissions",
@@ -66,12 +71,12 @@ KEYCLOAK_AUTH_MANAGER_COMMANDS = (
         func=lazy_load_command(
             
"airflow.providers.keycloak.auth_manager.cli.commands.create_permissions_command"
         ),
-        args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID),
+        args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID, 
ARG_DRY_RUN),
     ),
     ActionCommand(
         name="create-all",
         help="Create all entities (scopes, resources and permissions) in 
Keycloak",
         
func=lazy_load_command("airflow.providers.keycloak.auth_manager.cli.commands.create_all_command"),
-        args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID),
+        args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID, 
ARG_DRY_RUN),
     ),
 )
diff --git 
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/utils.py 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/utils.py
new file mode 100644
index 00000000000..8786e167c75
--- /dev/null
+++ 
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/utils.py
@@ -0,0 +1,81 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import functools
+from collections.abc import Callable
+from typing import Any
+
+
+def dry_run_message_wrap(func: Callable) -> Callable:
+    """Wrap CLI commands to display dry-run messages."""
+
+    @functools.wraps(func)
+    def wrapper(*args, **kwargs):
+        # detect args object (first positional or keyword)
+        if args:
+            arg_obj = args[0]
+        else:
+            arg_obj = kwargs.get("args")
+
+        dry_run = getattr(arg_obj, "dry_run", False)
+
+        if dry_run:
+            print(
+                "Performing dry run. "
+                "It will check the connection to Keycloak but won't create any 
resources.\n"
+            )
+
+        result = func(*args, **kwargs)
+
+        if dry_run:
+            print("Dry run completed.")
+
+        return result
+
+    return wrapper
+
+
+def dry_run_preview(preview_func: Callable[..., None]) -> Callable:
+    """
+    Handle dry-run preview logic for create functions.
+
+    When dry_run=True, executes the preview function and returns early.
+    Otherwise, proceeds with normal execution without passing dry_run
+    to the decorated function.
+
+    :param preview_func: Function to call for previewing what would be created.
+                        Should accept the same arguments as the decorated 
function.
+    """
+
+    def decorator(func: Callable) -> Callable:
+        @functools.wraps(func)
+        def wrapper(*args, **kwargs) -> Any:
+            # Extract dry_run from kwargs (default to False if not provided)
+            dry_run = kwargs.pop("_dry_run", False)
+
+            if dry_run:
+                # Pass args and remaining kwargs to preview function
+                preview_func(*args, **kwargs)
+                return None
+
+            # Pass args and remaining kwargs to actual function
+            return func(*args, **kwargs)
+
+        return wrapper
+
+    return decorator
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 f8767164f53..f5c244e5394 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
@@ -26,10 +26,6 @@ from airflow.api_fastapi.auth.managers.base_auth_manager 
import ResourceMethod
 from airflow.api_fastapi.common.types import MenuItem
 from airflow.cli import cli_parser
 from airflow.providers.keycloak.auth_manager.cli.commands import (
-    _create_admin_permission,
-    _create_op_permission,
-    _create_read_only_permission,
-    _create_user_permission,
     create_all_command,
     create_permissions_command,
     create_resources_command,
@@ -170,28 +166,26 @@ class TestCommands:
             )
         client.create_client_authz_resource.assert_has_calls(calls)
 
-    
@patch("airflow.providers.keycloak.auth_manager.cli.commands._create_op_permission")
-    
@patch("airflow.providers.keycloak.auth_manager.cli.commands._create_user_permission")
-    
@patch("airflow.providers.keycloak.auth_manager.cli.commands._create_admin_permission")
-    
@patch("airflow.providers.keycloak.auth_manager.cli.commands._create_read_only_permission")
     @patch("airflow.providers.keycloak.auth_manager.cli.commands._get_client")
     def test_create_permissions(
         self,
         mock_get_client,
-        mock_create_read_only_permission,
-        mock_create_admin_permission,
-        mock__create_user_permission,
-        mock_create_op_permission,
     ):
         client = Mock()
         mock_get_client.return_value = client
-        scopes = [{"id": "1", "name": "GET"}]
+        scopes = [{"id": "1", "name": "GET"}, {"id": "2", "name": "MENU"}, 
{"id": "3", "name": "LIST"}]
+        resources = [
+            {"_id": "r1", "name": "Dag"},
+            {"_id": "r2", "name": "Asset"},
+            {"_id": "r3", "name": "Connection"},
+        ]
 
         client.get_clients.return_value = [
             {"id": "dummy-id", "clientId": "dummy-client"},
             {"id": "test-id", "clientId": "test_client_id"},
         ]
         client.get_client_authz_scopes.return_value = scopes
+        client.get_client_authz_resources.return_value = resources
 
         params = [
             "keycloak-auth-manager",
@@ -209,10 +203,60 @@ class TestCommands:
             create_permissions_command(self.arg_parser.parse_args(params))
 
         client.get_clients.assert_called_once_with()
-        mock_create_read_only_permission.assert_called_once_with(client, 
"test-id")
-        mock_create_admin_permission.assert_called_once_with(client, "test-id")
-        mock__create_user_permission.assert_called_once_with(client, "test-id")
-        mock_create_op_permission.assert_called_once_with(client, "test-id")
+        client.get_client_authz_scopes.assert_called_once_with("test-id")
+        client.get_client_authz_resources.assert_called_once_with("test-id")
+
+        # Verify scope-based permissions are created with correct payloads
+        scope_calls = [
+            call(
+                client_id="test-id",
+                payload={
+                    "name": "ReadOnly",
+                    "type": "scope",
+                    "logic": "POSITIVE",
+                    "decisionStrategy": "UNANIMOUS",
+                    "scopes": ["1", "2", "3"],  # GET, MENU, LIST
+                },
+            ),
+            call(
+                client_id="test-id",
+                payload={
+                    "name": "Admin",
+                    "type": "scope",
+                    "logic": "POSITIVE",
+                    "decisionStrategy": "UNANIMOUS",
+                    "scopes": ["1", "2", "3"],  # GET, MENU, LIST (only these 
exist in mock)
+                },
+            ),
+        ]
+        
client.create_client_authz_scope_permission.assert_has_calls(scope_calls, 
any_order=True)
+
+        # Verify resource-based permissions are created with correct payloads
+        resource_calls = [
+            call(
+                client_id="test-id",
+                payload={
+                    "name": "User",
+                    "type": "scope",
+                    "logic": "POSITIVE",
+                    "decisionStrategy": "UNANIMOUS",
+                    "resources": ["r1", "r2"],  # Dag, Asset
+                },
+                skip_exists=True,
+            ),
+            call(
+                client_id="test-id",
+                payload={
+                    "name": "Op",
+                    "type": "scope",
+                    "logic": "POSITIVE",
+                    "decisionStrategy": "UNANIMOUS",
+                    "resources": ["r3"],  # Connection
+                },
+                skip_exists=True,
+            ),
+        ]
+        
client.create_client_authz_resource_based_permission.assert_has_calls(resource_calls,
 any_order=True)
 
     
@patch("airflow.providers.keycloak.auth_manager.cli.commands._create_permissions")
     
@patch("airflow.providers.keycloak.auth_manager.cli.commands._create_resources")
@@ -251,102 +295,150 @@ class TestCommands:
             create_all_command(self.arg_parser.parse_args(params))
 
         client.get_clients.assert_called_once_with()
-        mock_create_scopes.assert_called_once_with(client, "test-id")
-        mock_create_resources.assert_called_once_with(client, "test-id")
-        mock_create_permissions.assert_called_once_with(client, "test-id")
+        mock_create_scopes.assert_called_once_with(client, "test-id", 
_dry_run=False)
+        mock_create_resources.assert_called_once_with(client, "test-id", 
_dry_run=False)
+        mock_create_permissions.assert_called_once_with(client, "test-id", 
_dry_run=False)
 
-    def test_create_permissions_read_only(self):
+    @patch("airflow.providers.keycloak.auth_manager.cli.commands._get_client")
+    def test_create_scopes_dry_run(self, mock_get_client):
         client = Mock()
-        scopes = [{"id": "1", "name": "GET"}, {"id": "2", "name": "MENU"}, 
{"id": "3", "name": "PUT"}]
+        mock_get_client.return_value = client
 
-        client.get_client_authz_scopes.return_value = scopes
+        client.get_clients.return_value = [
+            {"id": "dummy-id", "clientId": "dummy-client"},
+            {"id": "test-id", "clientId": "test_client_id"},
+        ]
+
+        params = [
+            "keycloak-auth-manager",
+            "create-scopes",
+            "--username",
+            "test",
+            "--password",
+            "test",
+            "--dry-run",
+        ]
+        with conf_vars(
+            {
+                ("keycloak_auth_manager", "client_id"): "test_client_id",
+            }
+        ):
+            create_scopes_command(self.arg_parser.parse_args(params))
 
-        _create_read_only_permission(client, "test-id")
+        client.get_clients.assert_called_once_with()
+        # In dry-run mode, no scopes should be created
+        client.create_client_authz_scopes.assert_not_called()
 
-        client.get_client_authz_scopes.assert_called_once_with("test-id")
-        client.create_client_authz_scope_permission.assert_called_once_with(
-            client_id="test-id",
-            payload={
-                "name": "ReadOnly",
-                "type": "scope",
-                "logic": "POSITIVE",
-                "decisionStrategy": "UNANIMOUS",
-                "scopes": ["1", "2"],
-            },
-        )
-
-    def test_create_permissions_admin(self):
+    @patch("airflow.providers.keycloak.auth_manager.cli.commands._get_client")
+    def test_create_resources_dry_run(self, mock_get_client):
         client = Mock()
-        scopes = [
-            {"id": "1", "name": "GET"},
-            {"id": "2", "name": "MENU"},
-            {"id": "3", "name": "PUT"},
-            {"id": "4", "name": "LIST"},
-            {"id": "5", "name": "DUMMY"},
-        ]
+        mock_get_client.return_value = client
+        scopes = [{"id": "1", "name": "GET"}, {"id": "2", "name": "MENU"}]
 
+        client.get_clients.return_value = [
+            {"id": "dummy-id", "clientId": "dummy-client"},
+            {"id": "test-id", "clientId": "test_client_id"},
+        ]
         client.get_client_authz_scopes.return_value = scopes
 
-        _create_admin_permission(client, "test-id")
+        params = [
+            "keycloak-auth-manager",
+            "create-resources",
+            "--username",
+            "test",
+            "--password",
+            "test",
+            "--dry-run",
+        ]
+        with conf_vars(
+            {
+                ("keycloak_auth_manager", "client_id"): "test_client_id",
+            }
+        ):
+            create_resources_command(self.arg_parser.parse_args(params))
 
+        client.get_clients.assert_called_once_with()
         client.get_client_authz_scopes.assert_called_once_with("test-id")
-        client.create_client_authz_scope_permission.assert_called_once_with(
-            client_id="test-id",
-            payload={
-                "name": "Admin",
-                "type": "scope",
-                "logic": "POSITIVE",
-                "decisionStrategy": "UNANIMOUS",
-                "scopes": ["1", "2", "3", "4"],
-            },
-        )
-
-    def test_create_permissions_user(self):
+        # In dry-run mode, no resources should be created
+        client.create_client_authz_resource.assert_not_called()
+
+    @patch("airflow.providers.keycloak.auth_manager.cli.commands._get_client")
+    def test_create_permissions_dry_run(self, mock_get_client):
         client = Mock()
+        mock_get_client.return_value = client
+        scopes = [{"id": "1", "name": "GET"}, {"id": "2", "name": "MENU"}, 
{"id": "3", "name": "LIST"}]
         resources = [
-            {"_id": "1", "name": "Dag"},
-            {"_id": "2", "name": "Asset"},
-            {"_id": "3", "name": "Variable"},
+            {"_id": "r1", "name": "Dag"},
+            {"_id": "r2", "name": "Asset"},
         ]
 
+        client.get_clients.return_value = [
+            {"id": "dummy-id", "clientId": "dummy-client"},
+            {"id": "test-id", "clientId": "test_client_id"},
+        ]
+        client.get_client_authz_scopes.return_value = scopes
         client.get_client_authz_resources.return_value = resources
 
-        _create_user_permission(client, "test-id")
+        params = [
+            "keycloak-auth-manager",
+            "create-permissions",
+            "--username",
+            "test",
+            "--password",
+            "test",
+            "--dry-run",
+        ]
+        with conf_vars(
+            {
+                ("keycloak_auth_manager", "client_id"): "test_client_id",
+            }
+        ):
+            create_permissions_command(self.arg_parser.parse_args(params))
 
+        client.get_clients.assert_called_once_with()
+        client.get_client_authz_scopes.assert_called_once_with("test-id")
         client.get_client_authz_resources.assert_called_once_with("test-id")
-        
client.create_client_authz_resource_based_permission.assert_called_once_with(
-            client_id="test-id",
-            payload={
-                "name": "User",
-                "type": "scope",
-                "logic": "POSITIVE",
-                "decisionStrategy": "UNANIMOUS",
-                "resources": ["1", "2"],
-            },
-            skip_exists=True,
-        )
-
-    def test_create_permissions_op(self):
+        # In dry-run mode, no permissions should be created
+        client.create_client_authz_scope_permission.assert_not_called()
+        
client.create_client_authz_resource_based_permission.assert_not_called()
+
+    
@patch("airflow.providers.keycloak.auth_manager.cli.commands._create_permissions")
+    
@patch("airflow.providers.keycloak.auth_manager.cli.commands._create_resources")
+    
@patch("airflow.providers.keycloak.auth_manager.cli.commands._create_scopes")
+    @patch("airflow.providers.keycloak.auth_manager.cli.commands._get_client")
+    def test_create_all_dry_run(
+        self,
+        mock_get_client,
+        mock_create_scopes,
+        mock_create_resources,
+        mock_create_permissions,
+    ):
         client = Mock()
-        resources = [
-            {"_id": "1", "name": "Dag"},
-            {"_id": "2", "name": "Connection"},
-            {"_id": "3", "name": "Variable"},
-        ]
+        mock_get_client.return_value = client
 
-        client.get_client_authz_resources.return_value = resources
+        client.get_clients.return_value = [
+            {"id": "dummy-id", "clientId": "dummy-client"},
+            {"id": "test-id", "clientId": "test_client_id"},
+        ]
 
-        _create_op_permission(client, "test-id")
+        params = [
+            "keycloak-auth-manager",
+            "create-all",
+            "--username",
+            "test",
+            "--password",
+            "test",
+            "--dry-run",
+        ]
+        with conf_vars(
+            {
+                ("keycloak_auth_manager", "client_id"): "test_client_id",
+            }
+        ):
+            create_all_command(self.arg_parser.parse_args(params))
 
-        client.get_client_authz_resources.assert_called_once_with("test-id")
-        
client.create_client_authz_resource_based_permission.assert_called_once_with(
-            client_id="test-id",
-            payload={
-                "name": "Op",
-                "type": "scope",
-                "logic": "POSITIVE",
-                "decisionStrategy": "UNANIMOUS",
-                "resources": ["2", "3"],
-            },
-            skip_exists=True,
-        )
+        client.get_clients.assert_called_once_with()
+        # In dry-run mode, all helper functions should be called with 
dry_run=True
+        mock_create_scopes.assert_called_once_with(client, "test-id", 
_dry_run=True)
+        mock_create_resources.assert_called_once_with(client, "test-id", 
_dry_run=True)
+        mock_create_permissions.assert_called_once_with(client, "test-id", 
_dry_run=True)
diff --git 
a/providers/keycloak/tests/unit/keycloak/auth_manager/cli/test_utils.py 
b/providers/keycloak/tests/unit/keycloak/auth_manager/cli/test_utils.py
new file mode 100644
index 00000000000..0a599568ff6
--- /dev/null
+++ b/providers/keycloak/tests/unit/keycloak/auth_manager/cli/test_utils.py
@@ -0,0 +1,61 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+from airflow.providers.keycloak.auth_manager.cli.utils import 
dry_run_message_wrap, dry_run_preview
+
+
+class TestDryRunMessageWrap:
+    def test_prints_messages_when_dry_run_true(self, capsys):
+        @dry_run_message_wrap
+        def test_func(args):
+            return "executed"
+
+        args = MagicMock()
+        args.dry_run = True
+        result = test_func(args)
+
+        captured = capsys.readouterr()
+        assert "Performing dry run" in captured.out
+        assert "Dry run completed" in captured.out
+        assert result == "executed"
+
+
+class TestDryRunPreview:
+    def test_calls_preview_when_dry_run_true(self):
+        preview_called = []
+
+        def preview_func(*args, **kwargs):
+            preview_called.append(True)
+
+        @dry_run_preview(preview_func)
+        def actual_func(*args, **kwargs):
+            return "actual"
+
+        result = actual_func(_dry_run=True)
+        assert result is None
+        assert len(preview_called) == 1
+
+    def test_calls_actual_when_dry_run_false(self):
+        @dry_run_preview(lambda *a, **k: None)
+        def actual_func(*args, **kwargs):
+            return "actual"
+
+        result = actual_func(_dry_run=False)
+        assert result == "actual"


Reply via email to