Skip to content

Commit 8a4de86

Browse files
authored
Implement menu authorization in KeycloakAuthManager (#51845)
1 parent c2ae4a3 commit 8a4de86

File tree

4 files changed

+133
-8
lines changed

4 files changed

+133
-8
lines changed

providers/keycloak/src/airflow/providers/keycloak/auth_manager/cli/commands.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from keycloak import KeycloakAdmin, KeycloakError
2424

2525
from airflow.api_fastapi.auth.managers.base_auth_manager import ResourceMethod
26+
from airflow.api_fastapi.common.types import MenuItem
2627
from airflow.configuration import conf
2728
from airflow.providers.keycloak.auth_manager.constants import (
2829
CONF_CLIENT_ID_KEY,
@@ -70,7 +71,7 @@ def create_permissions_command(args):
7071
@cli_utils.action_cli
7172
@providers_configuration_loaded
7273
def create_all_command(args):
73-
"""Create Keycloak auth manager scopes in Keycloak."""
74+
"""Create all Keycloak auth manager entities in Keycloak."""
7475
client = _get_client(args)
7576
client_uuid = _get_client_uuid(args)
7677

@@ -115,12 +116,11 @@ def _create_scopes(client: KeycloakAdmin, client_uuid: str):
115116

116117

117118
def _create_resources(client: KeycloakAdmin, client_uuid: str):
118-
# Fetch existing scopes
119119
all_scopes = client.get_client_authz_scopes(client_uuid)
120120
scopes = [
121121
{"id": scope["id"], "name": scope["name"]}
122122
for scope in all_scopes
123-
if scope["name"] in get_args(ResourceMethod)
123+
if scope["name"] in ["GET", "POST", "PUT", "DELETE"]
124124
]
125125

126126
for resource in KeycloakResource:
@@ -133,6 +133,19 @@ def _create_resources(client: KeycloakAdmin, client_uuid: str):
133133
skip_exists=True,
134134
)
135135

136+
# Create menu item resources
137+
scopes = [{"id": scope["id"], "name": scope["name"]} for scope in all_scopes if scope["name"] == "MENU"]
138+
139+
for item in MenuItem:
140+
client.create_client_authz_resource(
141+
client_id=client_uuid,
142+
payload={
143+
"name": item.value,
144+
"scopes": scopes,
145+
},
146+
skip_exists=True,
147+
)
148+
136149
print("Resources created successfully.")
137150

138151

providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@
1818

1919
import json
2020
import logging
21-
from typing import TYPE_CHECKING, Any
21+
from typing import TYPE_CHECKING, Any, cast
2222
from urllib.parse import urljoin
2323

2424
import requests
2525
from fastapi import FastAPI
2626

2727
from airflow.api_fastapi.app import AUTH_MANAGER_FASTAPI_APP_PREFIX
2828
from airflow.api_fastapi.auth.managers.base_auth_manager import BaseAuthManager
29+
from airflow.api_fastapi.common.types import MenuItem
2930
from airflow.cli.cli_config import CLICommand, GroupCommand
3031
from airflow.configuration import conf
3132
from airflow.exceptions import AirflowException
@@ -54,7 +55,6 @@
5455
PoolDetails,
5556
VariableDetails,
5657
)
57-
from airflow.api_fastapi.common.types import MenuItem
5858

5959
log = logging.getLogger(__name__)
6060

@@ -198,7 +198,11 @@ def is_authorized_custom_view(
198198
def filter_authorized_menu_items(
199199
self, menu_items: list[MenuItem], *, user: KeycloakAuthManagerUser
200200
) -> list[MenuItem]:
201-
return menu_items
201+
authorized_menus = self._is_batch_authorized(
202+
permissions=[(cast("ResourceMethod", "MENU"), menu_item.value) for menu_item in menu_items],
203+
user=user,
204+
)
205+
return [MenuItem(menu[1]) for menu in authorized_menus]
202206

203207
def get_fastapi_app(self) -> FastAPI | None:
204208
from airflow.providers.keycloak.auth_manager.routes.login import login_router
@@ -260,6 +264,33 @@ def _is_authorized(
260264
)
261265
raise AirflowException(f"Unexpected error: {resp.status_code} - {resp.text}")
262266

267+
def _is_batch_authorized(
268+
self,
269+
*,
270+
permissions: list[tuple[ResourceMethod, str]],
271+
user: KeycloakAuthManagerUser,
272+
) -> set[tuple[ResourceMethod, str]]:
273+
client_id = conf.get(CONF_SECTION_NAME, CONF_CLIENT_ID_KEY)
274+
realm = conf.get(CONF_SECTION_NAME, CONF_REALM_KEY)
275+
server_url = conf.get(CONF_SECTION_NAME, CONF_SERVER_URL_KEY)
276+
277+
resp = requests.post(
278+
self._get_token_url(server_url, realm),
279+
data=self._get_batch_payload(client_id, permissions),
280+
headers=self._get_headers(user.access_token),
281+
)
282+
283+
if resp.status_code == 200:
284+
return {(perm["scopes"][0], perm["rsname"]) for perm in resp.json()}
285+
if resp.status_code == 403:
286+
return set()
287+
if resp.status_code == 400:
288+
error = json.loads(resp.text)
289+
raise AirflowException(
290+
f"Request not recognized by Keycloak. {error.get('error')}. {error.get('error_description')}"
291+
)
292+
raise AirflowException(f"Unexpected error: {resp.status_code} - {resp.text}")
293+
263294
@staticmethod
264295
def _get_token_url(server_url, realm):
265296
return f"{server_url}/realms/{realm}/protocol/openid-connect/token"
@@ -276,6 +307,17 @@ def _get_payload(client_id: str, permission: str, attributes: dict[str, str] | N
276307

277308
return payload
278309

310+
@staticmethod
311+
def _get_batch_payload(client_id: str, permissions: list[tuple[ResourceMethod, str]]):
312+
payload: dict[str, Any] = {
313+
"grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket",
314+
"audience": client_id,
315+
"permission": [f"{permission[1]}#{permission[0]}" for permission in permissions],
316+
"response_mode": "permissions",
317+
}
318+
319+
return payload
320+
279321
@staticmethod
280322
def _get_headers(access_token):
281323
return {

providers/keycloak/tests/unit/keycloak/auth_manager/cli/test_commands.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import pytest
2424

2525
from airflow.api_fastapi.auth.managers.base_auth_manager import ResourceMethod
26+
from airflow.api_fastapi.common.types import MenuItem
2627
from airflow.cli import cli_parser
2728
from airflow.providers.keycloak.auth_manager.cli.commands import (
2829
_create_admin_permission,
@@ -116,7 +117,7 @@ def test_create_scopes_with_client_not_found(self, mock_get_client):
116117
def test_create_resources(self, mock_get_client):
117118
client = Mock()
118119
mock_get_client.return_value = client
119-
scopes = [{"id": "1", "name": "GET"}]
120+
scopes = [{"id": "1", "name": "GET"}, {"id": "2", "name": "MENU"}]
120121

121122
client.get_clients.return_value = [
122123
{"id": "dummy-id", "clientId": "dummy-client"},
@@ -148,7 +149,21 @@ def test_create_resources(self, mock_get_client):
148149
client_id="test-id",
149150
payload={
150151
"name": resource.value,
151-
"scopes": scopes,
152+
"scopes": [{"id": "1", "name": "GET"}],
153+
},
154+
skip_exists=True,
155+
)
156+
)
157+
client.create_client_authz_resource.assert_has_calls(calls)
158+
159+
calls = []
160+
for item in MenuItem:
161+
calls.append(
162+
call(
163+
client_id="test-id",
164+
payload={
165+
"name": item.value,
166+
"scopes": [{"id": "2", "name": "MENU"}],
152167
},
153168
skip_exists=True,
154169
)

providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
PoolDetails,
3535
VariableDetails,
3636
)
37+
from airflow.api_fastapi.common.types import MenuItem
3738
from airflow.exceptions import AirflowException
3839
from airflow.providers.keycloak.auth_manager.constants import (
3940
CONF_CLIENT_ID_KEY,
@@ -369,5 +370,59 @@ def test_is_authorized_custom_view(
369370
mock_requests.post.assert_called_once_with(token_url, data=payload, headers=headers)
370371
assert result == expected
371372

373+
@pytest.mark.parametrize(
374+
"status_code, response, expected",
375+
[
376+
[
377+
200,
378+
[{"scopes": ["MENU"], "rsname": "Assets"}, {"scopes": ["MENU"], "rsname": "Connections"}],
379+
{MenuItem.ASSETS, MenuItem.CONNECTIONS},
380+
],
381+
[200, [{"scopes": ["MENU"], "rsname": "Assets"}], {MenuItem.ASSETS}],
382+
[200, [], set()],
383+
[403, [{"scopes": ["MENU"], "rsname": "Assets"}], set()],
384+
],
385+
)
386+
@patch("airflow.providers.keycloak.auth_manager.keycloak_auth_manager.requests")
387+
def test_filter_authorized_menu_items(
388+
self, mock_requests, status_code, response, expected, auth_manager, user
389+
):
390+
mock_requests.post.return_value.status_code = status_code
391+
mock_requests.post.return_value.json.return_value = response
392+
menu_items = [MenuItem.ASSETS, MenuItem.CONNECTIONS]
393+
394+
result = auth_manager.filter_authorized_menu_items(menu_items, user=user)
395+
396+
token_url = auth_manager._get_token_url("server_url", "realm")
397+
payload = auth_manager._get_batch_payload(
398+
"client_id", [("MENU", MenuItem.ASSETS.value), ("MENU", MenuItem.CONNECTIONS.value)]
399+
)
400+
headers = auth_manager._get_headers("access_token")
401+
mock_requests.post.assert_called_once_with(token_url, data=payload, headers=headers)
402+
assert set(result) == expected
403+
404+
@pytest.mark.parametrize(
405+
"status_code",
406+
[400, 500],
407+
)
408+
@patch("airflow.providers.keycloak.auth_manager.keycloak_auth_manager.requests")
409+
def test_filter_authorized_menu_items_with_failure(self, mock_requests, status_code, auth_manager, user):
410+
resp = Mock()
411+
resp.status_code = status_code
412+
resp.text = json.dumps({})
413+
mock_requests.post.return_value = resp
414+
415+
menu_items = [MenuItem.ASSETS, MenuItem.CONNECTIONS]
416+
417+
with pytest.raises(AirflowException):
418+
auth_manager.filter_authorized_menu_items(menu_items, user=user)
419+
420+
token_url = auth_manager._get_token_url("server_url", "realm")
421+
payload = auth_manager._get_batch_payload(
422+
"client_id", [("MENU", MenuItem.ASSETS.value), ("MENU", MenuItem.CONNECTIONS.value)]
423+
)
424+
headers = auth_manager._get_headers("access_token")
425+
mock_requests.post.assert_called_once_with(token_url, data=payload, headers=headers)
426+
372427
def test_get_cli_commands_return_cli_commands(self, auth_manager):
373428
assert len(auth_manager.get_cli_commands()) == 1

0 commit comments

Comments
 (0)