Skip to content

Commit 197907e

Browse files
authored
KeycloakAuthManager - Create CLI to create resources in KeyCloak (apache#51691)
1 parent 4ad4291 commit 197907e

File tree

13 files changed

+811
-14
lines changed

13 files changed

+811
-14
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,7 @@ repos:
648648
^providers/google/src/airflow/providers/google/cloud/operators/cloud_build\.py$|
649649
^providers/google/src/airflow/providers/google/cloud/operators/dataproc\.py$|
650650
^providers/google/src/airflow/providers/google/cloud/operators/mlengine\.py$|
651+
^providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/definition.py|
651652
^providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/cosmos\.py$|
652653
^providers/microsoft/winrm/src/airflow/providers/microsoft/winrm/hooks/winrm\.py$|
653654
^airflow-core/docs/.*commits\.rst$|
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
from __future__ import annotations
18+
19+
import json
20+
import logging
21+
from typing import get_args
22+
23+
from keycloak import KeycloakAdmin, KeycloakError
24+
25+
from airflow.api_fastapi.auth.managers.base_auth_manager import ResourceMethod
26+
from airflow.configuration import conf
27+
from airflow.providers.keycloak.auth_manager.constants import (
28+
CONF_CLIENT_ID_KEY,
29+
CONF_REALM_KEY,
30+
CONF_SECTION_NAME,
31+
CONF_SERVER_URL_KEY,
32+
)
33+
from airflow.providers.keycloak.auth_manager.resources import KeycloakResource
34+
from airflow.utils import cli as cli_utils
35+
from airflow.utils.providers_configuration_loader import providers_configuration_loaded
36+
37+
log = logging.getLogger(__name__)
38+
39+
40+
@cli_utils.action_cli
41+
@providers_configuration_loaded
42+
def create_scopes_command(args):
43+
"""Create Keycloak auth manager scopes in Keycloak."""
44+
client = _get_client(args)
45+
client_uuid = _get_client_uuid(args)
46+
47+
_create_scopes(client, client_uuid)
48+
49+
50+
@cli_utils.action_cli
51+
@providers_configuration_loaded
52+
def create_resources_command(args):
53+
"""Create Keycloak auth manager resources in Keycloak."""
54+
client = _get_client(args)
55+
client_uuid = _get_client_uuid(args)
56+
57+
_create_resources(client, client_uuid)
58+
59+
60+
@cli_utils.action_cli
61+
@providers_configuration_loaded
62+
def create_permissions_command(args):
63+
"""Create Keycloak auth manager permissions in Keycloak."""
64+
client = _get_client(args)
65+
client_uuid = _get_client_uuid(args)
66+
67+
_create_permissions(client, client_uuid)
68+
69+
70+
@cli_utils.action_cli
71+
@providers_configuration_loaded
72+
def create_all_command(args):
73+
"""Create Keycloak auth manager scopes in Keycloak."""
74+
client = _get_client(args)
75+
client_uuid = _get_client_uuid(args)
76+
77+
_create_scopes(client, client_uuid)
78+
_create_resources(client, client_uuid)
79+
_create_permissions(client, client_uuid)
80+
81+
82+
def _get_client(args):
83+
server_url = conf.get(CONF_SECTION_NAME, CONF_SERVER_URL_KEY)
84+
realm = conf.get(CONF_SECTION_NAME, CONF_REALM_KEY)
85+
86+
return KeycloakAdmin(
87+
server_url=server_url,
88+
username=args.username,
89+
password=args.password,
90+
realm_name=realm,
91+
user_realm_name=args.user_realm,
92+
client_id=args.client_id,
93+
verify=True,
94+
)
95+
96+
97+
def _get_client_uuid(args):
98+
client = _get_client(args)
99+
clients = client.get_clients()
100+
client_id = conf.get(CONF_SECTION_NAME, CONF_CLIENT_ID_KEY)
101+
102+
matches = [client for client in clients if client["clientId"] == client_id]
103+
if not matches:
104+
raise ValueError(f"Client with ID='{client_id}' not found in realm '{client.realm_name}'")
105+
106+
return matches[0]["id"]
107+
108+
109+
def _create_scopes(client: KeycloakAdmin, client_uuid: str):
110+
scopes = [{"name": method} for method in get_args(ResourceMethod)]
111+
for scope in scopes:
112+
client.create_client_authz_scopes(client_id=client_uuid, payload=scope)
113+
114+
print("Scopes created successfully.")
115+
116+
117+
def _create_resources(client: KeycloakAdmin, client_uuid: str):
118+
# Fetch existing scopes
119+
all_scopes = client.get_client_authz_scopes(client_uuid)
120+
scopes = [
121+
{"id": scope["id"], "name": scope["name"]}
122+
for scope in all_scopes
123+
if scope["name"] in get_args(ResourceMethod)
124+
]
125+
126+
for resource in KeycloakResource:
127+
client.create_client_authz_resource(
128+
client_id=client_uuid,
129+
payload={
130+
"name": resource.value,
131+
"scopes": scopes,
132+
},
133+
skip_exists=True,
134+
)
135+
136+
print("Resources created successfully.")
137+
138+
139+
def _create_permissions(client: KeycloakAdmin, client_uuid: str):
140+
_create_read_only_permission(client, client_uuid)
141+
_create_admin_permission(client, client_uuid)
142+
_create_user_permission(client, client_uuid)
143+
_create_op_permission(client, client_uuid)
144+
145+
print("Permissions created successfully.")
146+
147+
148+
def _create_read_only_permission(client: KeycloakAdmin, client_uuid: str):
149+
all_scopes = client.get_client_authz_scopes(client_uuid)
150+
scopes = [scope["id"] for scope in all_scopes if scope["name"] in ["GET", "MENU"]]
151+
payload = {
152+
"name": "ReadOnly",
153+
"type": "scope",
154+
"logic": "POSITIVE",
155+
"decisionStrategy": "UNANIMOUS",
156+
"scopes": scopes,
157+
}
158+
_create_permission(client, client_uuid, payload)
159+
160+
161+
def _create_admin_permission(client: KeycloakAdmin, client_uuid: str):
162+
all_scopes = client.get_client_authz_scopes(client_uuid)
163+
scopes = [scope["id"] for scope in all_scopes if scope["name"] in get_args(ResourceMethod)]
164+
payload = {
165+
"name": "Admin",
166+
"type": "scope",
167+
"logic": "POSITIVE",
168+
"decisionStrategy": "UNANIMOUS",
169+
"scopes": scopes,
170+
}
171+
_create_permission(client, client_uuid, payload)
172+
173+
174+
def _create_user_permission(client: KeycloakAdmin, client_uuid: str):
175+
_create_resource_based_permission(
176+
client, client_uuid, "User", [KeycloakResource.DAG.value, KeycloakResource.ASSET.value]
177+
)
178+
179+
180+
def _create_op_permission(client: KeycloakAdmin, client_uuid: str):
181+
_create_resource_based_permission(
182+
client,
183+
client_uuid,
184+
"Op",
185+
[
186+
KeycloakResource.CONNECTION.value,
187+
KeycloakResource.POOL.value,
188+
KeycloakResource.VARIABLE.value,
189+
KeycloakResource.BACKFILL.value,
190+
],
191+
)
192+
193+
194+
def _create_permission(client: KeycloakAdmin, client_uuid: str, payload: dict):
195+
try:
196+
client.create_client_authz_scope_permission(
197+
client_id=client_uuid,
198+
payload=payload,
199+
)
200+
except KeycloakError as e:
201+
if e.response_body:
202+
error = json.loads(e.response_body.decode("utf-8"))
203+
if error.get("error_description") == "Conflicting policy":
204+
print(f"Policy creation skipped. {error.get('error')}")
205+
206+
207+
def _create_resource_based_permission(
208+
client: KeycloakAdmin, client_uuid: str, name: str, allowed_resources: list[str]
209+
):
210+
all_resources = client.get_client_authz_resources(client_uuid)
211+
resources = [resource["_id"] for resource in all_resources if resource["name"] in allowed_resources]
212+
213+
payload = {
214+
"name": name,
215+
"type": "scope",
216+
"logic": "POSITIVE",
217+
"decisionStrategy": "UNANIMOUS",
218+
"resources": resources,
219+
}
220+
client.create_client_authz_resource_based_permission(
221+
client_id=client_uuid,
222+
payload=payload,
223+
skip_exists=True,
224+
)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
from __future__ import annotations
19+
20+
from airflow.cli.cli_config import (
21+
ActionCommand,
22+
Arg,
23+
lazy_load_command,
24+
)
25+
26+
############
27+
# # ARGS # #
28+
############
29+
30+
ARG_USERNAME = Arg(
31+
("--username",),
32+
help="Username associated to the user used to create resources",
33+
)
34+
ARG_PASSWORD = Arg(
35+
("--password",),
36+
help="Password associated to the user used to create resources",
37+
)
38+
ARG_USER_REALM = Arg(
39+
("--user-realm",), help="Realm name where the user used to create resources is", default="master"
40+
)
41+
ARG_CLIENT_ID = Arg(("--client-id",), help="ID of the client used to create resources", default="admin-cli")
42+
43+
44+
################
45+
# # COMMANDS # #
46+
################
47+
48+
KEYCLOAK_AUTH_MANAGER_COMMANDS = (
49+
ActionCommand(
50+
name="create-scopes",
51+
help="Create scopes in Keycloak",
52+
func=lazy_load_command("airflow.providers.keycloak.auth_manager.cli.commands.create_scopes_command"),
53+
args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID),
54+
),
55+
ActionCommand(
56+
name="create-resources",
57+
help="Create resources in Keycloak",
58+
func=lazy_load_command(
59+
"airflow.providers.keycloak.auth_manager.cli.commands.create_resources_command"
60+
),
61+
args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID),
62+
),
63+
ActionCommand(
64+
name="create-permissions",
65+
help="Create permissions in Keycloak",
66+
func=lazy_load_command(
67+
"airflow.providers.keycloak.auth_manager.cli.commands.create_permissions_command"
68+
),
69+
args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID),
70+
),
71+
ActionCommand(
72+
name="create-all",
73+
help="Create all entities (scopes, resources and permissions) in Keycloak",
74+
func=lazy_load_command("airflow.providers.keycloak.auth_manager.cli.commands.create_all_command"),
75+
args=(ARG_USERNAME, ARG_PASSWORD, ARG_USER_REALM, ARG_CLIENT_ID),
76+
),
77+
)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
# Configuration keys
19+
from __future__ import annotations
20+
21+
CONF_SECTION_NAME = "keycloak_auth_manager"
22+
CONF_CLIENT_ID_KEY = "client_id"
23+
CONF_CLIENT_SECRET_KEY = "client_secret"
24+
CONF_REALM_KEY = "realm"
25+
CONF_SERVER_URL_KEY = "server_url"

0 commit comments

Comments
 (0)