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 61e5e8992ad Keycloak CLI: provision multi‑team resources for auth
manager (AIP‑67) (#61256)
61e5e8992ad is described below
commit 61e5e8992ad93f0eba291181e5ef65c533fbd08f
Author: Mathieu Monet <[email protected]>
AuthorDate: Thu Feb 19 20:46:02 2026 +0100
Keycloak CLI: provision multi‑team resources for auth manager (AIP‑67)
(#61256)
* feat: update keycloak CLI for multiteam setup
* feat: align team-scoped resources permissions with model and add global
list permission in the cli
* docs: refine language in permissions documentation to be more assertive
* fix: update policy name format by removing redundant 'Team-' prefix
* feat: add global scoped resources and update permissions to include them
* reflect changes on auth_ manager on keycloak resource creation
* feat: add 'Jobs' to the CLI menu options in test_commands
* refactor: streamline resource names in permission creation by using
MenuItem enumeration
---
.../docs/auth-manager/manage/permissions.rst | 44 +-
.../keycloak/auth_manager/cli/commands.py | 920 ++++++++++++++++++++-
.../airflow/providers/keycloak/cli/definition.py | 23 +-
.../keycloak/auth_manager/cli/test_commands.py | 384 ++++++++-
.../tests/unit/keycloak/cli/test_definition.py | 2 +-
5 files changed, 1315 insertions(+), 58 deletions(-)
diff --git a/providers/keycloak/docs/auth-manager/manage/permissions.rst
b/providers/keycloak/docs/auth-manager/manage/permissions.rst
index 7e72cfddf09..15b0b074079 100644
--- a/providers/keycloak/docs/auth-manager/manage/permissions.rst
+++ b/providers/keycloak/docs/auth-manager/manage/permissions.rst
@@ -44,6 +44,7 @@ CLI commands take the following parameters:
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.
+* ``--teams``: Comma-separated list of team names to create team-scoped
resources and permissions.
Please check the `Keycloak auth manager CLI </cli-ref.html>`_ documentation
for more information about accepted parameters.
@@ -58,6 +59,12 @@ This command will create scopes, resources and permissions
in one-go.
airflow keycloak-auth-manager create-all
+To create team-scoped resources and permissions (so Keycloak can enforce
per-team access) pass ``--teams``:
+
+.. code-block:: bash
+
+ airflow keycloak-auth-manager create-all --teams team-a,team-b
+
Step-by-step creation of permissions
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@@ -73,20 +80,45 @@ This command will create resources for certain types of
permissions.
.. code-block:: bash
- airflow keycloak-auth-manager create-resources
+ airflow keycloak-auth-manager create-resources --teams team-a,team-b
Finally, with the command below, we create the permissions using the
previously created scopes and resources.
.. code-block:: bash
- airflow keycloak-auth-manager create-permissions
+ airflow keycloak-auth-manager create-permissions --teams team-a,team-b
This will create
-* read-only permissions
-* admin permissions
-* user permissions
-* operations permissions
+* read-only permissions (per-team when ``--teams`` is provided)
+* admin permissions (global)
+* user permissions (per-team when ``--teams`` is provided)
+* operations permissions (per-team when ``--teams`` is provided)
+
+Managing teams with Keycloak
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+When using team-scoped resources, create Keycloak groups that represent teams
and attach them to the
+team-specific permissions. The CLI provides helpers for this flow:
+
+.. code-block:: bash
+
+ airflow keycloak-auth-manager create-team team-a
+ airflow keycloak-auth-manager add-user-to-team user-a team-a
+
+These commands create a Keycloak group named ``team-a``, set up team-scoped
resources and permissions,
+and attach team-specific policies to the permissions for that team.
+When using team-scoped permissions, the model is:
+
+* Keycloak group represents the team (``<name>``)
+* Keycloak roles (Admin/Op/User/Viewer) represent the role within that team
+* Team policies require both group membership and role membership
+* A separate ``SuperAdmin`` role can be used for global admin access across
all teams
+
+Note: the CLI creates groups, resources, permissions, and policies, but **does
not assign roles to users**.
+You must assign the appropriate Keycloak roles (Admin/Op/User/Viewer or
SuperAdmin) to each user separately.
+In multi-team mode, the ``Admin`` role is **team-scoped** (group + role). Only
``SuperAdmin`` grants global
+admin access across all teams.
More resources about permissions can be found in the official documentation of
Keycloak:
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 0baaa32d8e7..997b90ae026 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
@@ -22,6 +22,7 @@ from enum import Enum
from typing import get_args
from keycloak import KeycloakAdmin, KeycloakError
+from keycloak.exceptions import KeycloakGetError, KeycloakPostError,
raise_error_from_response
from airflow.api_fastapi.auth.managers.base_auth_manager import ResourceMethod
from airflow.api_fastapi.common.types import MenuItem
@@ -46,6 +47,30 @@ except ImportError:
)
log = logging.getLogger(__name__)
+TEAM_SCOPED_RESOURCE_NAMES = {
+ KeycloakResource.DAG.value,
+ KeycloakResource.CONNECTION.value,
+ KeycloakResource.VARIABLE.value,
+ KeycloakResource.POOL.value,
+}
+GLOBAL_SCOPED_RESOURCE_NAMES = {
+ KeycloakResource.ASSET.value,
+ KeycloakResource.ASSET_ALIAS.value,
+ KeycloakResource.CONFIGURATION.value,
+}
+TEAM_MENU_ITEMS = {
+ MenuItem.DAGS,
+ MenuItem.ASSETS,
+ MenuItem.DOCS,
+}
+TEAM_ADMIN_MENU_ITEMS = TEAM_MENU_ITEMS | {
+ MenuItem.CONNECTIONS,
+ MenuItem.POOLS,
+ MenuItem.VARIABLES,
+ MenuItem.XCOMS,
+}
+TEAM_ROLE_NAMES = ("Viewer", "User", "Op", "Admin")
+SUPER_ADMIN_ROLE_NAME = "SuperAdmin"
def _get_resource_methods() -> list[str]:
@@ -90,8 +115,10 @@ def create_resources_command(args):
"""Create Keycloak auth manager resources in Keycloak."""
client = _get_client(args)
client_uuid = _get_client_uuid(args)
+ teams = _parse_teams(args.teams)
+ _ensure_multi_team_enabled(teams=teams, command_name="create-resources")
- _create_resources(client, client_uuid, _dry_run=args.dry_run)
+ _create_resources(client, client_uuid, teams=teams, _dry_run=args.dry_run)
@cli_utils.action_cli
@@ -102,7 +129,14 @@ def create_permissions_command(args):
client = _get_client(args)
client_uuid = _get_client_uuid(args)
- _create_permissions(client, client_uuid, _dry_run=args.dry_run)
+ teams = _parse_teams(args.teams)
+ _ensure_multi_team_enabled(teams=teams, command_name="create-permissions")
+ if teams:
+ # Role policies are only needed for team-scoped (group+role)
authorization.
+ for role_name in TEAM_ROLE_NAMES:
+ _ensure_role_policy(client, client_uuid, role_name,
_dry_run=args.dry_run)
+ _ensure_role_policy(client, client_uuid, SUPER_ADMIN_ROLE_NAME,
_dry_run=args.dry_run)
+ _create_permissions(client, client_uuid, teams=teams,
_dry_run=args.dry_run)
@cli_utils.action_cli
@@ -112,10 +146,18 @@ def create_all_command(args):
"""Create all Keycloak auth manager entities in Keycloak."""
client = _get_client(args)
client_uuid = _get_client_uuid(args)
+ teams = _parse_teams(args.teams)
+ _ensure_multi_team_enabled(teams=teams, command_name="create-all")
_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)
+ _create_resources(client, client_uuid, teams=teams, _dry_run=args.dry_run)
+ _create_group_membership_mapper(client, client_uuid, _dry_run=args.dry_run)
+ if teams:
+ # Role policies are only needed for team-scoped (group+role)
authorization.
+ for role_name in TEAM_ROLE_NAMES:
+ _ensure_role_policy(client, client_uuid, role_name,
_dry_run=args.dry_run)
+ _ensure_role_policy(client, client_uuid, SUPER_ADMIN_ROLE_NAME,
_dry_run=args.dry_run)
+ _create_permissions(client, client_uuid, teams=teams,
_dry_run=args.dry_run)
def _get_client(args):
@@ -145,6 +187,42 @@ def _get_client_uuid(args):
return matches[0]["id"]
+def _create_group_membership_mapper(
+ client: KeycloakAdmin, client_uuid: str, *, _dry_run: bool = False
+) -> None:
+ realm = client.connection.realm_name
+ url = f"admin/realms/{realm}/clients/{client_uuid}/protocol-mappers/models"
+ data_raw = client.connection.raw_get(url)
+ mappers = json.loads(data_raw.text)
+ for mapper in mappers:
+ if mapper.get("name") == "groups":
+ return
+ if mapper.get("protocolMapper") == "oidc-group-membership-mapper":
+ if mapper.get("config", {}).get("claim.name") == "groups":
+ return
+
+ if _dry_run:
+ print("Would create protocol mapper 'groups'.")
+ return
+
+ payload = {
+ "name": "groups",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-group-membership-mapper",
+ "consentRequired": False,
+ "config": {
+ "full.path": "false",
+ "id.token.claim": "false",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "false",
+ "claim.name": "groups",
+ "jsonType.label": "String",
+ },
+ }
+ data_raw = client.connection.raw_post(url, data=json.dumps(payload),
max=-1)
+ raise_error_from_response(data_raw, KeycloakPostError,
expected_codes=[201])
+
+
def _get_scopes_to_create() -> list[dict]:
"""Get the list of scopes to be created."""
scopes = [{"name": method} for method in _get_resource_methods()]
@@ -152,6 +230,19 @@ def _get_scopes_to_create() -> list[dict]:
return scopes
+def _parse_teams(teams: str | None) -> list[str]:
+ if not teams:
+ return []
+ return [team.strip() for team in teams.split(",") if team.strip()]
+
+
+def _ensure_multi_team_enabled(*, teams: list[str], command_name: str) -> None:
+ if not teams:
+ return
+ if not conf.getboolean("core", "multi_team", fallback=False):
+ raise SystemExit(f"{command_name} requires core.multi_team=True when
--teams is used.")
+
+
def _preview_scopes(*args, **kwargs):
"""Preview scopes that would be created."""
scopes = _get_scopes_to_create()
@@ -175,6 +266,7 @@ def _create_scopes(client: KeycloakAdmin, client_uuid: str,
*, _dry_run: bool =
def _get_resources_to_create(
client: KeycloakAdmin,
client_uuid: str,
+ teams: list[str],
) -> tuple[list[tuple[str, list[dict]]], list[tuple[str, list[dict]]]]:
"""
Get the list of resources to be created.
@@ -194,14 +286,24 @@ def _get_resources_to_create(
]
standard_resources = [(resource.value, scopes) for resource in
KeycloakResource]
+
+ if teams:
+ existing = {resource_name for resource_name, _ in standard_resources}
+ for team in teams:
+ for resource_name in TEAM_SCOPED_RESOURCE_NAMES:
+ name = f"{resource_name}:{team}"
+ if name in existing:
+ continue
+ standard_resources.append((name, scopes))
+ existing.add(name)
menu_resources = [(item.value, menu_scopes) for item in MenuItem]
return standard_resources, menu_resources
-def _preview_resources(client: KeycloakAdmin, client_uuid: str):
+def _preview_resources(client: KeycloakAdmin, client_uuid: str, teams:
list[str]):
"""Preview resources that would be created."""
- standard_resources, menu_resources = _get_resources_to_create(client,
client_uuid)
+ standard_resources, menu_resources = _get_resources_to_create(client,
client_uuid, teams=teams)
print("Resources to be created:")
if standard_resources:
@@ -216,9 +318,9 @@ def _preview_resources(client: KeycloakAdmin, client_uuid:
str):
@dry_run_preview(_preview_resources)
-def _create_resources(client: KeycloakAdmin, client_uuid: str, *, _dry_run:
bool = False):
+def _create_resources(client: KeycloakAdmin, client_uuid: str, *, teams:
list[str], _dry_run: bool = False):
"""Create Keycloak resources."""
- standard_resources, menu_resources = _get_resources_to_create(client,
client_uuid)
+ standard_resources, menu_resources = _get_resources_to_create(client,
client_uuid, teams=teams)
for resource_name, scopes in standard_resources:
client.create_client_authz_resource(
@@ -244,39 +346,118 @@ def _create_resources(client: KeycloakAdmin,
client_uuid: str, *, _dry_run: bool
print("Resources created successfully.")
-def _get_permissions_to_create(client: KeycloakAdmin, client_uuid: str) ->
list[dict]:
+def _get_permissions_to_create(
+ client: KeycloakAdmin,
+ client_uuid: str,
+ teams: list[str],
+ *,
+ include_global_admin: bool = True,
+) -> 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": _get_extended_resource_methods() + ["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,
- ],
- },
- ]
+ if not teams:
+ perm_configs = [
+ {
+ "name": "ReadOnly",
+ "type": "scope-based",
+ "scope_names": ["GET", "MENU", "LIST"],
+ },
+ {
+ "name": "Admin",
+ "type": "scope-based",
+ "scope_names": _get_extended_resource_methods() + ["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,
+ ],
+ },
+ ]
+ else:
+ perm_configs = []
+ for team in teams:
+ perm_configs.extend(
+ [
+ {
+ "name": f"Admin-{team}",
+ "type": "scope-based",
+ "scope_names": _get_extended_resource_methods() +
["LIST"],
+ "resources": [f"{resource}:{team}" for resource in
TEAM_SCOPED_RESOURCE_NAMES],
+ },
+ {
+ "name": f"ReadOnly-{team}",
+ "type": "scope-based",
+ "scope_names": ["GET", "LIST"],
+ "resources": [
+ f"{KeycloakResource.DAG.value}:{team}",
+ ],
+ },
+ {
+ "name": f"User-{team}",
+ "type": "resource-based",
+ "resources": [
+ f"{KeycloakResource.DAG.value}:{team}",
+ ],
+ },
+ {
+ "name": f"Op-{team}",
+ "type": "resource-based",
+ "resources": [
+ f"{KeycloakResource.CONNECTION.value}:{team}",
+ f"{KeycloakResource.POOL.value}:{team}",
+ f"{KeycloakResource.VARIABLE.value}:{team}",
+ ],
+ },
+ ]
+ )
+ if include_global_admin:
+ perm_configs.append(
+ {
+ "name": "Admin",
+ "type": "scope-based",
+ "scope_names": _get_extended_resource_methods() + ["LIST"],
+ "resources": [
+ f"{resource}:{team}" for team in teams for resource in
TEAM_SCOPED_RESOURCE_NAMES
+ ],
+ }
+ )
+ perm_configs.append(
+ {
+ "name": "GlobalList",
+ "type": "scope-based",
+ "scope_names": ["LIST"],
+ "resources": list(TEAM_SCOPED_RESOURCE_NAMES) +
list(GLOBAL_SCOPED_RESOURCE_NAMES),
+ }
+ )
+ perm_configs.append(
+ {
+ "name": "ViewAccess",
+ "type": "scope-based",
+ "scope_names": ["GET"],
+ "resources": [KeycloakResource.VIEW.value],
+ }
+ )
+ perm_configs.append(
+ {
+ "name": "MenuAccess",
+ "type": "scope-based",
+ "scope_names": ["MENU"],
+ "resources": [item.value for item in MenuItem],
+ }
+ )
all_scopes = client.get_client_authz_scopes(client_uuid)
all_resources = client.get_client_authz_resources(client_uuid)
@@ -291,6 +472,13 @@ def _get_permissions_to_create(client: KeycloakAdmin,
client_uuid: str) -> list[
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
+ if "resources" in config:
+ 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
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"]]
@@ -302,15 +490,17 @@ def _get_permissions_to_create(client: KeycloakAdmin,
client_uuid: str) -> list[
return result
-def _preview_permissions(client: KeycloakAdmin, client_uuid: str):
+def _preview_permissions(client: KeycloakAdmin, client_uuid: str, teams:
list[str]):
"""Preview permissions that would be created."""
- permissions = _get_permissions_to_create(client, client_uuid)
+ permissions = _get_permissions_to_create(client, client_uuid, teams=teams)
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})")
+ resource_names = ", ".join(perm.get("resource_names", []))
+ resource_suffix = f", resources: {resource_names}" if
resource_names else ""
+ print(f" - {perm['name']} (type: scope-based, scopes:
{scope_names}{resource_suffix})")
else: # resource-based
resource_names = ", ".join(perm["resource_names"])
print(f" - {perm['name']} (type: resource-based, resources:
{resource_names})")
@@ -318,27 +508,47 @@ def _preview_permissions(client: KeycloakAdmin,
client_uuid: str):
@dry_run_preview(_preview_permissions)
-def _create_permissions(client: KeycloakAdmin, client_uuid: str, *, _dry_run:
bool = False):
+def _create_permissions(
+ client: KeycloakAdmin,
+ client_uuid: str,
+ *,
+ teams: list[str],
+ include_global_admin: bool = True,
+ _dry_run: bool = False,
+):
"""Create Keycloak permissions."""
- permissions = _get_permissions_to_create(client, client_uuid)
+ permissions = _get_permissions_to_create(
+ client, client_uuid, teams=teams,
include_global_admin=include_global_admin
+ )
for perm in permissions:
if perm["type"] == "scope-based":
- _create_scope_based_permission(client, client_uuid, perm["name"],
perm["scope_ids"])
+ _create_scope_based_permission(
+ client, client_uuid, perm["name"], perm["scope_ids"],
perm.get("resource_ids", [])
+ )
else: # resource-based
_create_resource_based_permission(client, client_uuid,
perm["name"], perm["resource_ids"])
print("Permissions created successfully.")
-def _create_scope_based_permission(client: KeycloakAdmin, client_uuid: str,
name: str, scope_ids: list[str]):
+def _create_scope_based_permission(
+ client: KeycloakAdmin,
+ client_uuid: str,
+ name: str,
+ scope_ids: list[str],
+ resource_ids: list[str] | None = None,
+ decision_strategy: str = "UNANIMOUS",
+):
payload = {
"name": name,
"type": "scope",
"logic": "POSITIVE",
- "decisionStrategy": "UNANIMOUS",
+ "decisionStrategy": decision_strategy,
"scopes": scope_ids,
}
+ if resource_ids:
+ payload["resources"] = resource_ids
try:
client.create_client_authz_scope_permission(
@@ -367,3 +577,625 @@ def _create_resource_based_permission(
payload=payload,
skip_exists=True,
)
+
+
+def _ensure_scope_permission(
+ client: KeycloakAdmin,
+ client_uuid: str,
+ *,
+ name: str,
+ scope_names: list[str],
+ resource_names: list[str],
+ decision_strategy: str = "UNANIMOUS",
+ _dry_run: bool = False,
+) -> None:
+ if _dry_run:
+ print(f"Would create scope permission '{name}'.")
+ return
+
+ permissions = client.get_client_authz_permissions(client_uuid)
+ if any(perm.get("name") == name for perm in permissions):
+ return
+
+ 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 scope_names]
+ resource_ids = [r["_id"] for r in resources if r["name"] in resource_names]
+ _create_scope_based_permission(
+ client,
+ client_uuid,
+ name,
+ scope_ids,
+ resource_ids,
+ decision_strategy=decision_strategy,
+ )
+
+
+def _update_admin_permission_resources(
+ client: KeycloakAdmin, client_uuid: str, *, _dry_run: bool = False
+) -> None:
+ if _dry_run:
+ print("Would update permission 'Admin' with team-scoped and global
resources.")
+ return
+
+ permissions = client.get_client_authz_permissions(client_uuid)
+ match = next((perm for perm in permissions if perm.get("name") ==
"Admin"), 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_names = _get_extended_resource_methods() + ["LIST"]
+ scope_ids = [s["id"] for s in scopes if s["name"] in scope_names]
+
+ resource_ids = [
+ r["_id"]
+ for r in resources
+ if any(r["name"].startswith(f"{resource}:") for resource in
TEAM_SCOPED_RESOURCE_NAMES)
+ or r["name"] in GLOBAL_SCOPED_RESOURCE_NAMES
+ ]
+
+ policy_ids = _get_permission_policy_ids(client, client_uuid, permission_id)
+ payload = {
+ "id": permission_id,
+ "name": "Admin",
+ "type": "scope",
+ "logic": "POSITIVE",
+ "decisionStrategy": "UNANIMOUS",
+ "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
+ )
+
+
+@cli_utils.action_cli
+@providers_configuration_loaded
+@dry_run_message_wrap
+def create_team_command(args):
+ """Create team resources, permissions, and Keycloak group."""
+ client = _get_client(args)
+ client_uuid = _get_client_uuid(args)
+ team = args.team
+ _ensure_multi_team_enabled(teams=[team], command_name="create-team")
+
+ _create_resources(client, client_uuid, teams=[team], _dry_run=args.dry_run)
+ _create_group_membership_mapper(client, client_uuid, _dry_run=args.dry_run)
+ _create_permissions(client, client_uuid, teams=[team],
include_global_admin=False, _dry_run=args.dry_run)
+ _ensure_group(client, team, _dry_run=args.dry_run)
+ _ensure_team_policies(client, client_uuid, team, _dry_run=args.dry_run)
+ _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_admin_permission_resources(client, client_uuid,
_dry_run=args.dry_run)
+
+
+@cli_utils.action_cli
+@providers_configuration_loaded
+@dry_run_message_wrap
+def add_user_to_team_command(args):
+ """Add a user to a Keycloak team group."""
+ client = _get_client(args)
+ team = args.team
+ username = args.target_username
+ _ensure_multi_team_enabled(teams=[team], command_name="add-user-to-team")
+
+ dry_run = getattr(args, "dry_run", False)
+ _ensure_group(client, team, _dry_run=dry_run)
+ _add_user_to_group(client, username=username, team=team, _dry_run=dry_run)
+
+
+def _ensure_team_policies(
+ client: KeycloakAdmin, client_uuid: str, team: str, *, _dry_run: bool =
False
+) -> None:
+ _ensure_group_policy(
+ client,
+ client_uuid,
+ team,
+ _dry_run=_dry_run,
+ )
+ for role_name in TEAM_ROLE_NAMES:
+ _ensure_aggregate_policy(
+ client,
+ client_uuid,
+ _team_role_policy_name(team, role_name),
+ [
+ (_team_group_policy_name(team), "group"),
+ (_role_policy_name(role_name), "role"),
+ ],
+ _dry_run=_dry_run,
+ )
+
+
+def _attach_team_permissions(
+ client: KeycloakAdmin, client_uuid: str, team: str, *, _dry_run: bool =
False
+) -> None:
+ team_dag_resources = [
+ f"{KeycloakResource.DAG.value}:{team}",
+ ]
+ team_scoped_resources = [f"{resource}:{team}" for resource in
sorted(TEAM_SCOPED_RESOURCE_NAMES)]
+
+ _attach_policy_to_scope_permission(
+ client,
+ client_uuid,
+ permission_name=f"ReadOnly-{team}",
+ policy_name=_team_role_policy_name(team, "Viewer"),
+ scope_names=["GET", "LIST"],
+ resource_names=team_dag_resources,
+ _dry_run=_dry_run,
+ )
+ for role_name in ("User", "Op", "Admin"):
+ _attach_policy_to_scope_permission(
+ client,
+ client_uuid,
+ permission_name=f"ReadOnly-{team}",
+ policy_name=_team_role_policy_name(team, role_name),
+ scope_names=["GET", "LIST"],
+ resource_names=team_dag_resources,
+ _dry_run=_dry_run,
+ )
+ _attach_policy_to_scope_permission(
+ client,
+ client_uuid,
+ permission_name=f"Admin-{team}",
+ policy_name=_team_role_policy_name(team, "Admin"),
+ scope_names=_get_extended_resource_methods() + ["LIST"],
+ resource_names=team_scoped_resources,
+ _dry_run=_dry_run,
+ )
+ _attach_policy_to_resource_permission(
+ client,
+ client_uuid,
+ permission_name=f"User-{team}",
+ policy_name=_team_role_policy_name(team, "User"),
+ resource_names=team_dag_resources,
+ _dry_run=_dry_run,
+ )
+ _attach_policy_to_resource_permission(
+ client,
+ client_uuid,
+ permission_name=f"Op-{team}",
+ policy_name=_team_role_policy_name(team, "Op"),
+ resource_names=[
+ f"{KeycloakResource.CONNECTION.value}:{team}",
+ f"{KeycloakResource.POOL.value}:{team}",
+ f"{KeycloakResource.VARIABLE.value}:{team}",
+ ],
+ _dry_run=_dry_run,
+ )
+ for role_name in TEAM_ROLE_NAMES:
+ _attach_policy_to_scope_permission(
+ client,
+ client_uuid,
+ permission_name="ViewAccess",
+ policy_name=_team_role_policy_name(team, role_name),
+ scope_names=["GET"],
+ resource_names=[KeycloakResource.VIEW.value],
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=_dry_run,
+ )
+
+ _attach_global_list_permissions(client, client_uuid, _dry_run=_dry_run)
+
+
+def _attach_global_list_permissions(
+ client: KeycloakAdmin, client_uuid: str, *, _dry_run: bool = False
+) -> None:
+ resource_names = list(TEAM_SCOPED_RESOURCE_NAMES) +
list(GLOBAL_SCOPED_RESOURCE_NAMES)
+ for role_name in (*TEAM_ROLE_NAMES, SUPER_ADMIN_ROLE_NAME):
+ _attach_policy_to_scope_permission(
+ client,
+ client_uuid,
+ permission_name="GlobalList",
+ policy_name=_role_policy_name(role_name),
+ scope_names=["LIST"],
+ resource_names=resource_names,
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=_dry_run,
+ )
+
+
+def _attach_team_menu_permissions(
+ client: KeycloakAdmin, client_uuid: str, team: str, *, _dry_run: bool =
False
+) -> None:
+ menu_permission_name = f"MenuAccess-{team}"
+ menu_admin_permission_name = f"MenuAccess-Admin-{team}"
+ team_menu_resources = [item.value for item in sorted(TEAM_MENU_ITEMS,
key=lambda item: item.value)]
+ team_admin_menu_resources = [
+ item.value for item in sorted(TEAM_ADMIN_MENU_ITEMS, key=lambda item:
item.value)
+ ]
+ _ensure_scope_permission(
+ client,
+ client_uuid,
+ name=menu_permission_name,
+ scope_names=["MENU"],
+ resource_names=team_menu_resources,
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=_dry_run,
+ )
+ _ensure_scope_permission(
+ client,
+ client_uuid,
+ name=menu_admin_permission_name,
+ scope_names=["MENU"],
+ resource_names=team_admin_menu_resources,
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=_dry_run,
+ )
+ for role_name in TEAM_ROLE_NAMES:
+ _attach_policy_to_scope_permission(
+ client,
+ client_uuid,
+ permission_name=menu_permission_name,
+ policy_name=_team_role_policy_name(team, role_name),
+ scope_names=["MENU"],
+ resource_names=team_menu_resources,
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=_dry_run,
+ )
+ _attach_policy_to_scope_permission(
+ client,
+ client_uuid,
+ permission_name=menu_admin_permission_name,
+ policy_name=_team_role_policy_name(team, "Admin"),
+ scope_names=["MENU"],
+ resource_names=team_admin_menu_resources,
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=_dry_run,
+ )
+
+
+def _attach_superadmin_permissions(
+ client: KeycloakAdmin, client_uuid: str, team: str, *, _dry_run: bool =
False
+) -> None:
+ team_scoped_resources = [f"{resource}:{team}" for resource in
sorted(TEAM_SCOPED_RESOURCE_NAMES)]
+ _attach_policy_to_scope_permission(
+ client,
+ client_uuid,
+ permission_name="Admin",
+ policy_name=_role_policy_name(SUPER_ADMIN_ROLE_NAME),
+ scope_names=_get_extended_resource_methods() + ["LIST"],
+ resource_names=team_scoped_resources,
+ _dry_run=_dry_run,
+ )
+ _attach_policy_to_scope_permission(
+ client,
+ client_uuid,
+ permission_name="ViewAccess",
+ policy_name=_role_policy_name(SUPER_ADMIN_ROLE_NAME),
+ scope_names=["GET"],
+ resource_names=[KeycloakResource.VIEW.value],
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=_dry_run,
+ )
+ _attach_policy_to_scope_permission(
+ client,
+ client_uuid,
+ permission_name="MenuAccess",
+ policy_name=_role_policy_name(SUPER_ADMIN_ROLE_NAME),
+ scope_names=["MENU"],
+ resource_names=[item.value for item in sorted(MenuItem, key=lambda
item: item.value)],
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=_dry_run,
+ )
+
+
+def _team_group_name(team: str) -> str:
+ return team
+
+
+def _team_group_policy_name(team: str) -> str:
+ return f"Allow-Team-{team}"
+
+
+def _role_policy_name(role_name: str) -> str:
+ return f"Allow-{role_name}"
+
+
+def _team_role_policy_name(team: str, role_name: str) -> str:
+ return f"Allow-{role_name}-{team}"
+
+
+def _ensure_group(client: KeycloakAdmin, team: str, *, _dry_run: bool = False)
-> dict | None:
+ group_name = _team_group_name(team)
+ group_path = f"/{group_name}"
+ try:
+ group = client.get_group_by_path(group_path)
+ if group:
+ return group
+ except KeycloakError:
+ pass
+
+ if _dry_run:
+ print(f"Would create group '{group_name}'.")
+ return None
+
+ group_id = client.create_group(payload={"name": group_name},
skip_exists=True)
+ if not group_id:
+ group = client.get_group_by_path(group_path)
+ return group
+ return {"id": group_id, "name": group_name, "path": group_path}
+
+
+def _ensure_group_policy(
+ client: KeycloakAdmin, client_uuid: str, team: str, *, _dry_run: bool =
False
+) -> None:
+ group_name = _team_group_name(team)
+ group_path = f"/{group_name}"
+ policy_name = _team_group_policy_name(team)
+ try:
+ group = client.get_group_by_path(group_path)
+ except KeycloakError:
+ group = None
+ if not group:
+ raise ValueError(f"Group '{group_name}' not found.")
+
+ if _get_policy_id(client, client_uuid, policy_name, policy_type="group"):
+ return
+
+ payload = {
+ "name": policy_name,
+ "type": "group",
+ "logic": "POSITIVE",
+ "decisionStrategy": "UNANIMOUS",
+ "groups": [{"id": group["id"], "path": group_path}],
+ }
+
+ if _dry_run:
+ print(f"Would create group policy '{policy_name}'.")
+ return
+
+ url = _policy_url(client, client_uuid, policy_type="group")
+ data_raw = client.connection.raw_post(url, data=json.dumps(payload),
max=-1, permission=False)
+ try:
+ raise_error_from_response(data_raw, KeycloakPostError,
expected_codes=[201])
+ except KeycloakPostError as exc:
+ if exc.response_body:
+ error = json.loads(exc.response_body.decode("utf-8"))
+ if "Conflicting policy" in error.get("error_description", ""):
+ return
+ raise
+
+
+def _policy_url(client: KeycloakAdmin, client_uuid: str, *, policy_type: str |
None = None) -> str:
+ realm = client.connection.realm_name
+ if policy_type:
+ return
f"admin/realms/{realm}/clients/{client_uuid}/authz/resource-server/policy/{policy_type}"
+ return
f"admin/realms/{realm}/clients/{client_uuid}/authz/resource-server/policy"
+
+
+def _get_policy_id(
+ client: KeycloakAdmin, client_uuid: str, policy_name: str, *, policy_type:
str | None = None
+) -> str | None:
+ url = _policy_url(client, client_uuid, policy_type=policy_type)
+ data_raw = client.connection.raw_get(url)
+ policies = json.loads(data_raw.text)
+ match = next((policy for policy in policies if policy.get("name") ==
policy_name), None)
+ return match.get("id") if match else None
+
+
+def _get_role_id(client: KeycloakAdmin, client_uuid: str, role_name: str) ->
str:
+ try:
+ role = client.get_realm_role(role_name)
+ return role["id"]
+ except KeycloakGetError:
+ role = client.get_client_role(client_id=client_uuid,
role_name=role_name)
+ return role["id"]
+
+
+def _ensure_role_policy(
+ client: KeycloakAdmin, client_uuid: str, role_name: str, *, _dry_run: bool
= False
+) -> None:
+ policy_name = _role_policy_name(role_name)
+ if _get_policy_id(client, client_uuid, policy_name, policy_type="role"):
+ return
+
+ role_id = _get_role_id(client, client_uuid, role_name)
+ payload = {
+ "name": policy_name,
+ "type": "role",
+ "logic": "POSITIVE",
+ "decisionStrategy": "UNANIMOUS",
+ "roles": [{"id": role_id}],
+ }
+
+ if _dry_run:
+ print(f"Would create role policy '{policy_name}'.")
+ return
+
+ try:
+ client.create_client_authz_role_based_policy(client_id=client_uuid,
payload=payload, skip_exists=True)
+ except KeycloakError as exc:
+ if exc.response_body:
+ error = json.loads(exc.response_body.decode("utf-8"))
+ if "Conflicting policy" in error.get("error_description", ""):
+ return
+ raise
+
+
+def _ensure_aggregate_policy(
+ client: KeycloakAdmin,
+ client_uuid: str,
+ policy_name: str,
+ policy_refs: list[tuple[str, str | None]],
+ *,
+ _dry_run: bool = False,
+) -> None:
+ if _get_policy_id(client, client_uuid, policy_name,
policy_type="aggregate"):
+ return
+
+ policy_ids = []
+ for name, policy_type in policy_refs:
+ # Aggregate policy enforces group+role; missing inputs should fail
fast.
+ policy_id = _get_policy_id(client, client_uuid, name,
policy_type=policy_type)
+ if not policy_id:
+ policy_label = f"{policy_type} policy '{name}'" if policy_type
else f"policy '{name}'"
+ raise ValueError(f"{policy_label} not found.")
+ policy_ids.append(policy_id)
+
+ payload = {
+ "name": policy_name,
+ "type": "aggregate",
+ "logic": "POSITIVE",
+ "decisionStrategy": "UNANIMOUS",
+ "policies": policy_ids,
+ }
+
+ if _dry_run:
+ print(f"Would create aggregate policy '{policy_name}'.")
+ return
+
+ url = _policy_url(client, client_uuid, policy_type="aggregate")
+ data_raw = client.connection.raw_post(url, data=json.dumps(payload),
max=-1, permission=False)
+ try:
+ raise_error_from_response(data_raw, KeycloakPostError,
expected_codes=[201])
+ except KeycloakPostError as exc:
+ if exc.response_body:
+ error = json.loads(exc.response_body.decode("utf-8"))
+ if "Conflicting policy" in error.get("error_description", ""):
+ return
+ raise
+
+
+def _attach_policy_to_scope_permission(
+ client: KeycloakAdmin,
+ client_uuid: str,
+ *,
+ permission_name: str,
+ policy_name: str,
+ scope_names: list[str],
+ resource_names: list[str],
+ decision_strategy: str = "UNANIMOUS",
+ _dry_run: bool = False,
+) -> None:
+ if _dry_run:
+ print(f"Would attach policy '{policy_name}' to permission
'{permission_name}'.")
+ return
+
+ permissions = client.get_client_authz_permissions(client_uuid)
+ match = next((perm for perm in permissions if perm.get("name") ==
permission_name), None)
+ if not match:
+ raise ValueError(f"Permission '{permission_name}' not found.")
+
+ permission_id = match["id"]
+ policy_id = _get_policy_id(client, client_uuid, policy_name)
+ if not policy_id:
+ raise ValueError(f"Policy '{policy_name}' not found.")
+
+ existing_policy_ids = _get_permission_policy_ids(client, client_uuid,
permission_id)
+ policy_ids = list(dict.fromkeys([*existing_policy_ids, policy_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 scope_names]
+ resource_ids = [r["_id"] for r in resources if r["name"] in resource_names]
+
+ payload = {
+ "id": permission_id,
+ "name": permission_name,
+ "type": "scope",
+ "logic": "POSITIVE",
+ "decisionStrategy": decision_strategy,
+ "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 _get_permission_policy_ids(client: KeycloakAdmin, client_uuid: str,
permission_id: str) -> list[str]:
+ realm = client.connection.realm_name
+ url = (
+
f"admin/realms/{realm}/clients/{client_uuid}/authz/resource-server/permission/scope/"
+ f"{permission_id}/associatedPolicies"
+ )
+ data_raw = client.connection.raw_get(url)
+ policies = json.loads(data_raw.text)
+ return [policy.get("id") for policy in policies if policy.get("id")]
+
+
+def _attach_policy_to_resource_permission(
+ client: KeycloakAdmin,
+ client_uuid: str,
+ *,
+ permission_name: str,
+ policy_name: str,
+ resource_names: list[str],
+ decision_strategy: str = "UNANIMOUS",
+ _dry_run: bool = False,
+) -> None:
+ if _dry_run:
+ print(f"Would attach policy '{policy_name}' to permission
'{permission_name}'.")
+ return
+
+ permissions = client.get_client_authz_permissions(client_uuid)
+ match = next((perm for perm in permissions if perm.get("name") ==
permission_name), None)
+ if not match:
+ raise ValueError(f"Permission '{permission_name}' not found.")
+
+ permission_id = match["id"]
+ policy_id = _get_policy_id(client, client_uuid, policy_name)
+ if not policy_id:
+ raise ValueError(f"Policy '{policy_name}' not found.")
+
+ existing_policy_ids = _get_resource_permission_policy_ids(client,
client_uuid, permission_id)
+ policy_ids = list(dict.fromkeys([*existing_policy_ids, policy_id]))
+
+ resources = client.get_client_authz_resources(client_uuid)
+ resource_ids = [r["_id"] for r in resources if r["name"] in resource_names]
+
+ payload = {
+ "id": permission_id,
+ "name": permission_name,
+ "type": "resource",
+ "logic": "POSITIVE",
+ "decisionStrategy": decision_strategy,
+ "resources": resource_ids,
+ "scopes": [],
+ "policies": policy_ids,
+ }
+ client.update_client_authz_resource_permission(
+ payload=payload, client_id=client_uuid, resource_id=permission_id
+ )
+
+
+def _get_resource_permission_policy_ids(
+ client: KeycloakAdmin, client_uuid: str, permission_id: str
+) -> list[str]:
+ realm = client.connection.realm_name
+ url = (
+
f"admin/realms/{realm}/clients/{client_uuid}/authz/resource-server/permission/resource/"
+ f"{permission_id}/associatedPolicies"
+ )
+ data_raw = client.connection.raw_get(url)
+ policies = json.loads(data_raw.text)
+ return [policy.get("id") for policy in policies if policy.get("id")]
+
+
+def _add_user_to_group(client: KeycloakAdmin, *, username: str, team: str,
_dry_run: bool = False) -> None:
+ group_name = _team_group_name(team)
+ group_path = f"/{group_name}"
+ group = client.get_group_by_path(group_path)
+ if not group:
+ raise ValueError(f"Group '{group_name}' not found.")
+
+ users = client.get_users(query={"username": username})
+ user = next((u for u in users if u.get("username") == username), None)
+ if not user:
+ raise ValueError(f"User '{username}' not found.")
+
+ existing_groups = client.get_user_groups(user_id=user["id"])
+ if any(g.get("id") == group["id"] for g in existing_groups):
+ print(f"User '{username}' is already in group '{group_name}'.")
+ return
+
+ if _dry_run:
+ print(f"Would add user '{username}' to group '{group_name}'.")
+ return
+
+ client.group_user_add(user_id=user["id"], group_id=group["id"])
diff --git
a/providers/keycloak/src/airflow/providers/keycloak/cli/definition.py
b/providers/keycloak/src/airflow/providers/keycloak/cli/definition.py
index 218f272c079..dbeb71ca908 100644
--- a/providers/keycloak/src/airflow/providers/keycloak/cli/definition.py
+++ b/providers/keycloak/src/airflow/providers/keycloak/cli/definition.py
@@ -57,6 +57,9 @@ 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_TEAMS = Arg(("--teams",), help="Comma-separated list of team names")
+ARG_TEAM = Arg(("team",), help="Team name")
+ARG_TARGET_USER = Arg(("target_username",), help="Username to add to the team")
ARG_DRY_RUN = Arg(
("--dry-run",),
help="Perform a dry run without creating any resources",
@@ -81,7 +84,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,
ARG_DRY_RUN),
+ args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID,
ARG_TEAMS, ARG_DRY_RUN),
),
ActionCommand(
name="create-permissions",
@@ -89,13 +92,27 @@ 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,
ARG_DRY_RUN),
+ args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID,
ARG_TEAMS, 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,
ARG_DRY_RUN),
+ args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID,
ARG_TEAMS, ARG_DRY_RUN),
+ ),
+ ActionCommand(
+ name="create-team",
+ help="Create Keycloak team group, resources, and permissions",
+
func=lazy_load_command("airflow.providers.keycloak.auth_manager.cli.commands.create_team_command"),
+ args=(ARG_TEAM, ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM,
ARG_CLIENT_ID, ARG_DRY_RUN),
+ ),
+ ActionCommand(
+ name="add-user-to-team",
+ help="Add a Keycloak user to a team group",
+ func=lazy_load_command(
+
"airflow.providers.keycloak.auth_manager.cli.commands.add_user_to_team_command"
+ ),
+ args=(ARG_TARGET_USER, ARG_TEAM, ARG_USERNAME, ARG_PASSWORD,
ARG_USER_REALM, ARG_CLIENT_ID),
),
)
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 c8fa22d81f5..d80649c32ca 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
@@ -24,11 +24,15 @@ import pytest
from airflow.api_fastapi.common.types import MenuItem
from airflow.cli import cli_parser
from airflow.providers.keycloak.auth_manager.cli.commands import (
+ TEAM_SCOPED_RESOURCE_NAMES,
+ _get_extended_resource_methods,
_get_resource_methods,
+ add_user_to_team_command,
create_all_command,
create_permissions_command,
create_resources_command,
create_scopes_command,
+ create_team_command,
)
from airflow.providers.keycloak.auth_manager.resources import KeycloakResource
@@ -136,6 +140,7 @@ class TestCommands:
with conf_vars(
{
("keycloak_auth_manager", "client_id"): "test_client_id",
+ ("core", "multi_team"): "True",
}
):
create_resources_command(self.arg_parser.parse_args(params))
@@ -170,6 +175,44 @@ class TestCommands:
)
client.create_client_authz_resource.assert_has_calls(calls)
+ @patch("airflow.providers.keycloak.auth_manager.cli.commands._get_client")
+ def test_create_resources_with_teams(self, mock_get_client):
+ client = Mock()
+ 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
+
+ params = [
+ "keycloak-auth-manager",
+ "create-resources",
+ "--username",
+ "test",
+ "--password",
+ "test",
+ "--teams",
+ "team-a",
+ ]
+ with conf_vars(
+ {
+ ("keycloak_auth_manager", "client_id"): "test_client_id",
+ ("core", "multi_team"): "True",
+ }
+ ):
+ create_resources_command(self.arg_parser.parse_args(params))
+
+ expected_team_resources = {f"{name}:team-a" for name in
TEAM_SCOPED_RESOURCE_NAMES}
+ created_resource_names = {
+ call.kwargs["payload"]["name"]
+ for call in client.create_client_authz_resource.mock_calls
+ if "payload" in call.kwargs
+ }
+ assert expected_team_resources.issubset(created_resource_names)
+
@patch("airflow.providers.keycloak.auth_manager.cli.commands._get_client")
def test_create_permissions(
self,
@@ -190,6 +233,10 @@ class TestCommands:
]
client.get_client_authz_scopes.return_value = scopes
client.get_client_authz_resources.return_value = resources
+ client.connection = Mock()
+ client.connection.raw_get = Mock(return_value=Mock(text="[]"))
+ client.connection.realm_name = "test-realm"
+ client.get_realm_role.return_value = {"id": "role-id"}
params = [
"keycloak-auth-manager",
@@ -202,6 +249,7 @@ class TestCommands:
with conf_vars(
{
("keycloak_auth_manager", "client_id"): "test_client_id",
+ ("core", "multi_team"): "True",
}
):
create_permissions_command(self.arg_parser.parse_args(params))
@@ -262,6 +310,322 @@ class TestCommands:
]
client.create_client_authz_resource_based_permission.assert_has_calls(resource_calls,
any_order=True)
+ @patch("airflow.providers.keycloak.auth_manager.cli.commands._get_client")
+ def test_create_permissions_with_teams(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": "r1", "name": "Dag:team-a"},
+ {"_id": "r2", "name": "Connection:team-a"},
+ {"_id": "r3", "name": "Pool:team-a"},
+ {"_id": "r4", "name": "Variable:team-a"},
+ {"_id": "r5", "name": "View"},
+ {"_id": "r6", "name": "Dag"},
+ {"_id": "r7", "name": "Connection"},
+ {"_id": "r8", "name": "Pool"},
+ {"_id": "r9", "name": "Variable"},
+ {"_id": "r10", "name": "Asset"},
+ {"_id": "r11", "name": "AssetAlias"},
+ {"_id": "r12", "name": "Configuration"},
+ ]
+
+ 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
+ client.connection = Mock()
+ client.connection.raw_get = Mock(return_value=Mock(text="[]"))
+ client.connection.realm_name = "test-realm"
+ client.get_realm_role.return_value = {"id": "role-id"}
+
+ params = [
+ "keycloak-auth-manager",
+ "create-permissions",
+ "--username",
+ "test",
+ "--password",
+ "test",
+ "--teams",
+ "team-a",
+ ]
+ with conf_vars(
+ {
+ ("keycloak_auth_manager", "client_id"): "test_client_id",
+ ("core", "multi_team"): "True",
+ }
+ ):
+ create_permissions_command(self.arg_parser.parse_args(params))
+
+ client.create_client_authz_scope_permission.assert_any_call(
+ client_id="test-id",
+ payload={
+ "name": "Admin-team-a",
+ "type": "scope",
+ "logic": "POSITIVE",
+ "decisionStrategy": "UNANIMOUS",
+ "scopes": ["1", "2", "3"],
+ "resources": ["r1", "r2", "r3", "r4"],
+ },
+ )
+ client.create_client_authz_scope_permission.assert_any_call(
+ client_id="test-id",
+ payload={
+ "name": "GlobalList",
+ "type": "scope",
+ "logic": "POSITIVE",
+ "decisionStrategy": "UNANIMOUS",
+ "scopes": ["3"],
+ "resources": ["r6", "r7", "r8", "r9", "r10", "r11", "r12"],
+ },
+ )
+ client.create_client_authz_scope_permission.assert_any_call(
+ client_id="test-id",
+ payload={
+ "name": "ViewAccess",
+ "type": "scope",
+ "logic": "POSITIVE",
+ "decisionStrategy": "UNANIMOUS",
+ "scopes": ["1"],
+ "resources": ["r5"],
+ },
+ )
+ client.create_client_authz_scope_permission.assert_any_call(
+ client_id="test-id",
+ payload={
+ "name": "ReadOnly-team-a",
+ "type": "scope",
+ "logic": "POSITIVE",
+ "decisionStrategy": "UNANIMOUS",
+ "scopes": ["1", "3"],
+ "resources": ["r1"],
+ },
+ )
+ client.create_client_authz_resource_based_permission.assert_any_call(
+ client_id="test-id",
+ payload={
+ "name": "User-team-a",
+ "type": "scope",
+ "logic": "POSITIVE",
+ "decisionStrategy": "UNANIMOUS",
+ "resources": ["r1"],
+ },
+ skip_exists=True,
+ )
+
+
@patch("airflow.providers.keycloak.auth_manager.cli.commands._update_admin_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")
+
@patch("airflow.providers.keycloak.auth_manager.cli.commands._ensure_aggregate_policy")
+
@patch("airflow.providers.keycloak.auth_manager.cli.commands._ensure_group_policy")
+
@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._ensure_group")
+ @patch("airflow.providers.keycloak.auth_manager.cli.commands._get_client")
+ def test_create_team_command(
+ self,
+ mock_get_client,
+ mock_ensure_group,
+ mock_create_resources,
+ mock_create_permissions,
+ mock_ensure_group_policy,
+ mock_ensure_aggregate_policy,
+ mock_attach_policy,
+ mock_attach_resource_policy,
+ mock_ensure_scope_permission,
+ mock_update_admin_permission_resources,
+ ):
+ client = Mock()
+ mock_get_client.return_value = client
+ client.get_clients.return_value = [
+ {"id": "test-id", "clientId": "test_client_id"},
+ ]
+ client.connection = Mock()
+ client.connection.raw_get = Mock(return_value=Mock(text="[]"))
+ client.connection.raw_post = Mock(
+ return_value=Mock(status_code=201,
json=Mock(return_value={"message": ""}), text="{}")
+ )
+ client.connection.realm_name = "test-realm"
+
+ params = [
+ "keycloak-auth-manager",
+ "create-team",
+ "team-a",
+ "--username",
+ "test",
+ "--password",
+ "test",
+ ]
+ with conf_vars(
+ {
+ ("keycloak_auth_manager", "client_id"): "test_client_id",
+ ("core", "multi_team"): "True",
+ }
+ ):
+ create_team_command(self.arg_parser.parse_args(params))
+
+ mock_ensure_group.assert_called_once_with(client, "team-a",
_dry_run=False)
+ mock_create_resources.assert_called_once_with(client, "test-id",
teams=["team-a"], _dry_run=False)
+ mock_create_permissions.assert_called_once_with(
+ client, "test-id", teams=["team-a"], include_global_admin=False,
_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
+ mock_attach_policy.assert_any_call(
+ client,
+ "test-id",
+ permission_name="ReadOnly-team-a",
+ policy_name="Allow-Viewer-team-a",
+ scope_names=["GET", "LIST"],
+ resource_names=["Dag:team-a"],
+ _dry_run=False,
+ )
+ mock_attach_policy.assert_any_call(
+ client,
+ "test-id",
+ permission_name="Admin-team-a",
+ policy_name="Allow-Admin-team-a",
+ scope_names=_get_extended_resource_methods() + ["LIST"],
+ resource_names=[
+ "Connection:team-a",
+ "Dag:team-a",
+ "Pool:team-a",
+ "Variable:team-a",
+ ],
+ _dry_run=False,
+ )
+ mock_attach_resource_policy.assert_any_call(
+ client,
+ "test-id",
+ permission_name="User-team-a",
+ policy_name="Allow-User-team-a",
+ resource_names=["Dag:team-a"],
+ _dry_run=False,
+ )
+ mock_attach_resource_policy.assert_any_call(
+ client,
+ "test-id",
+ permission_name="Op-team-a",
+ policy_name="Allow-Op-team-a",
+ resource_names=[
+ "Connection:team-a",
+ "Pool:team-a",
+ "Variable:team-a",
+ ],
+ _dry_run=False,
+ )
+ mock_attach_policy.assert_any_call(
+ client,
+ "test-id",
+ permission_name="MenuAccess-team-a",
+ policy_name="Allow-Viewer-team-a",
+ scope_names=["MENU"],
+ resource_names=["Assets", "Dags", "Docs"],
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=False,
+ )
+ mock_attach_policy.assert_any_call(
+ client,
+ "test-id",
+ permission_name="MenuAccess-Admin-team-a",
+ policy_name="Allow-Admin-team-a",
+ scope_names=["MENU"],
+ resource_names=["Assets", "Connections", "Dags", "Docs", "Pools",
"Variables", "XComs"],
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=False,
+ )
+ mock_attach_policy.assert_any_call(
+ client,
+ "test-id",
+ permission_name="Admin",
+ policy_name="Allow-SuperAdmin",
+ scope_names=_get_extended_resource_methods() + ["LIST"],
+ resource_names=[
+ "Connection:team-a",
+ "Dag:team-a",
+ "Pool:team-a",
+ "Variable:team-a",
+ ],
+ _dry_run=False,
+ )
+ mock_attach_policy.assert_any_call(
+ client,
+ "test-id",
+ permission_name="MenuAccess",
+ policy_name="Allow-SuperAdmin",
+ scope_names=["MENU"],
+ resource_names=[item.value for item in sorted(MenuItem, key=lambda
item: item.value)],
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=False,
+ )
+ mock_attach_policy.assert_any_call(
+ client,
+ "test-id",
+ permission_name="ViewAccess",
+ policy_name="Allow-Viewer-team-a",
+ scope_names=["GET"],
+ resource_names=["View"],
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=False,
+ )
+ mock_ensure_scope_permission.assert_any_call(
+ client,
+ "test-id",
+ name="MenuAccess-team-a",
+ scope_names=["MENU"],
+ resource_names=["Assets", "Dags", "Docs"],
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=False,
+ )
+ mock_ensure_scope_permission.assert_any_call(
+ client,
+ "test-id",
+ name="MenuAccess-Admin-team-a",
+ scope_names=["MENU"],
+ resource_names=["Assets", "Connections", "Dags", "Docs", "Pools",
"Variables", "XComs"],
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=False,
+ )
+
+
@patch("airflow.providers.keycloak.auth_manager.cli.commands._add_user_to_group")
+
@patch("airflow.providers.keycloak.auth_manager.cli.commands._ensure_group")
+ @patch("airflow.providers.keycloak.auth_manager.cli.commands._get_client")
+ def test_add_user_to_team_command(self, mock_get_client,
mock_ensure_group, mock_add_user):
+ client = Mock()
+ mock_get_client.return_value = client
+ client.get_clients.return_value = [
+ {"id": "test-id", "clientId": "test_client_id"},
+ ]
+
+ params = [
+ "keycloak-auth-manager",
+ "add-user-to-team",
+ "user-a",
+ "team-a",
+ "--username",
+ "admin",
+ "--password",
+ "admin",
+ ]
+ with conf_vars(
+ {
+ ("keycloak_auth_manager", "client_id"): "test_client_id",
+ ("core", "multi_team"): "True",
+ }
+ ):
+ add_user_to_team_command(self.arg_parser.parse_args(params))
+
+ mock_ensure_group.assert_called_once_with(client, "team-a",
_dry_run=False)
+ mock_add_user.assert_called_once_with(client, username="user-a",
team="team-a", _dry_run=False)
+
@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")
@@ -282,6 +646,12 @@ class TestCommands:
{"id": "test-id", "clientId": "test_client_id"},
]
client.get_client_authz_scopes.return_value = scopes
+ client.connection = Mock()
+ client.connection.raw_get = Mock(return_value=Mock(text="[]"))
+ client.connection.raw_post = Mock(
+ return_value=Mock(status_code=201,
json=Mock(return_value={"message": ""}), text="{}")
+ )
+ client.connection.realm_name = "test-realm"
params = [
"keycloak-auth-manager",
@@ -300,8 +670,8 @@ class TestCommands:
client.get_clients.assert_called_once_with()
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)
+ mock_create_resources.assert_called_once_with(client, "test-id",
teams=[], _dry_run=False)
+ mock_create_permissions.assert_called_once_with(client, "test-id",
teams=[], _dry_run=False)
@patch("airflow.providers.keycloak.auth_manager.cli.commands._get_client")
def test_create_scopes_dry_run(self, mock_get_client):
@@ -424,6 +794,12 @@ class TestCommands:
{"id": "dummy-id", "clientId": "dummy-client"},
{"id": "test-id", "clientId": "test_client_id"},
]
+ client.connection = Mock()
+ client.connection.raw_get = Mock(return_value=Mock(text="[]"))
+ client.connection.raw_post = Mock(
+ return_value=Mock(status_code=201,
json=Mock(return_value={"message": ""}), text="{}")
+ )
+ client.connection.realm_name = "test-realm"
params = [
"keycloak-auth-manager",
@@ -444,5 +820,5 @@ class TestCommands:
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)
+ mock_create_resources.assert_called_once_with(client, "test-id",
teams=[], _dry_run=True)
+ mock_create_permissions.assert_called_once_with(client, "test-id",
teams=[], _dry_run=True)
diff --git a/providers/keycloak/tests/unit/keycloak/cli/test_definition.py
b/providers/keycloak/tests/unit/keycloak/cli/test_definition.py
index d10a727f694..fd85e99479a 100644
--- a/providers/keycloak/tests/unit/keycloak/cli/test_definition.py
+++ b/providers/keycloak/tests/unit/keycloak/cli/test_definition.py
@@ -48,7 +48,7 @@ class TestKeycloakCliDefinition:
self.arg_parser = cli_parser.get_parser()
def test_keycloak_auth_manager_cli_commands(self):
- assert len(KEYCLOAK_AUTH_MANAGER_COMMANDS) == 4
+ assert len(KEYCLOAK_AUTH_MANAGER_COMMANDS) == 6
@pytest.mark.parametrize(
"command",