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"