From b78a446bebb415b9ce38e3f0f4bc8d86b4a939a3 Mon Sep 17 00:00:00 2001 From: Stephen Bracken Date: Fri, 26 Jun 2026 11:46:57 +0100 Subject: [PATCH 1/6] fix keycloak missing resource error check --- .../auth_manager/keycloak_auth_manager.py | 21 +++++++++++-------- .../test_keycloak_auth_manager.py | 4 ++-- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py index 545f78039942e..94a68d0868ca1 100644 --- a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py +++ b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py @@ -78,11 +78,11 @@ log = logging.getLogger(__name__) RESOURCE_ID_ATTRIBUTE_NAME = "resource_id" -KEYCLOAK_RESOURCE_NOT_FOUND_ERROR = "resource not found:" +KEYCLOAK_RESOURCE_NOT_FOUND_ERROR = "resource with id [{resource_name}] does not exist" -def _is_missing_keycloak_resource_response(status_code: int, text: Any) -> bool: - return status_code == 500 and isinstance(text, str) and KEYCLOAK_RESOURCE_NOT_FOUND_ERROR in text.lower() +def _is_missing_keycloak_resource_response(resource_name: str, status_code: int, text: Any) -> bool: + return status_code == 500 and isinstance(text, str) and KEYCLOAK_RESOURCE_NOT_FOUND_ERROR.format(resource_name=resource_name).lower() in text.lower() TEAM_SCOPED_RESOURCES = frozenset( @@ -407,16 +407,19 @@ def _is_authorized( server_url = conf.get(CONF_SECTION_NAME, CONF_SERVER_URL_KEY) context_attributes = prune_dict(attributes or {}) + + is_team_resource = bool( + team_name + and conf.getboolean("core", "multi_team", fallback=False) + and resource_type in TEAM_SCOPED_RESOURCES + ) + if resource_id: context_attributes[RESOURCE_ID_ATTRIBUTE_NAME] = resource_id elif method == "GET": method = "LIST" - if ( - team_name - and conf.getboolean("core", "multi_team", fallback=False) - and resource_type in TEAM_SCOPED_RESOURCES - ): + if is_team_resource: resource_name = f"{resource_type.value}:{team_name}" else: resource_name = resource_type.value @@ -441,7 +444,7 @@ def _is_authorized( raise AirflowException( f"Request not recognized by Keycloak. {error.get('error')}. {error.get('error_description')}" ) - if _is_missing_keycloak_resource_response(resp.status_code, resp.text): + if is_team_resource and _is_missing_keycloak_resource_response(resource_name, resp.status_code, resp.text): log.warning("Keycloak authorization resource is missing; denying access. Response: %s", resp.text) return False raise AirflowException(f"Unexpected error: {resp.status_code} - {resp.text}") diff --git a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py index 347e9944e5ef9..14f1c71862c12 100644 --- a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py +++ b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py @@ -389,7 +389,7 @@ def test_is_authorized_failure(self, function, auth_manager, user): def test_is_authorized_missing_keycloak_resource(self, auth_manager, user, caplog): resp = Mock() resp.status_code = 500 - resp.text = "resource not found: Dag:team-a" + resp.text = "Resource with id [Dag:team-a] does not exist." auth_manager.http_session.post = Mock(return_value=resp) caplog.set_level("WARNING", logger="airflow.providers.keycloak.auth_manager.keycloak_auth_manager") @@ -397,7 +397,7 @@ def test_is_authorized_missing_keycloak_resource(self, auth_manager, user, caplo assert result is False assert "Keycloak authorization resource is missing; denying access" in caplog.text - assert "resource not found: Dag:team-a" in caplog.text + assert "Resource with id [Dag:team-a] does not exist." in caplog.text @pytest.mark.parametrize( "function", From 91801c7621d12f7cc621fe8aaaade6fc1bef299e Mon Sep 17 00:00:00 2001 From: Stephen Bracken Date: Fri, 26 Jun 2026 12:02:31 +0100 Subject: [PATCH 2/6] add doc note on multi team keycloak resources --- providers/keycloak/docs/auth-manager/index.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/providers/keycloak/docs/auth-manager/index.rst b/providers/keycloak/docs/auth-manager/index.rst index e572649074465..cf7d0a1acb59c 100644 --- a/providers/keycloak/docs/auth-manager/index.rst +++ b/providers/keycloak/docs/auth-manager/index.rst @@ -54,3 +54,10 @@ It enables you to manage users, roles, groups, and permissions entirely within K :maxdepth: 2 manage/login + +**Note on Multi Team Clients** +When using the Keycloak auth manager with a multi-team deployment, you will need to keep the keycloak client up to date by creating the required policies and resources for each new team that is added. + +If a team has dags added to Airflow but they are not setup in the keycloak client, keycloak will return a 500 error on the ``/dags`` screen: +```Resource with id [Dag:] does not exist.``` +The keycloak auth manager will handle this error by registering it as a 'deny' response to preserve access to the ``/dags`` screen for other teams. \ No newline at end of file From ebc208e5589822e8e13842c761c11fdf0d8e1294 Mon Sep 17 00:00:00 2001 From: Stephen Bracken Date: Fri, 26 Jun 2026 12:32:02 +0100 Subject: [PATCH 3/6] fix tests --- .../keycloak/auth_manager/test_keycloak_auth_manager.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py index 14f1c71862c12..54b882ac46640 100644 --- a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py +++ b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py @@ -39,6 +39,7 @@ ) from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_7_PLUS, AIRFLOW_V_3_2_PLUS +from tests_common.test_utils.config import conf_vars if AIRFLOW_V_3_2_PLUS: from airflow.api_fastapi.auth.managers.models.resource_details import TeamDetails @@ -386,6 +387,8 @@ def test_is_authorized_failure(self, function, auth_manager, user): assert "Unexpected error" in str(e.value) + @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="team_name not supported before Airflow 3.2.0") + @conf_vars({("core", "multi_team"): "True"}) def test_is_authorized_missing_keycloak_resource(self, auth_manager, user, caplog): resp = Mock() resp.status_code = 500 @@ -393,7 +396,7 @@ def test_is_authorized_missing_keycloak_resource(self, auth_manager, user, caplo auth_manager.http_session.post = Mock(return_value=resp) caplog.set_level("WARNING", logger="airflow.providers.keycloak.auth_manager.keycloak_auth_manager") - result = auth_manager.is_authorized_dag(method="GET", details=DagDetails(id="dag_0"), user=user) + result = auth_manager.is_authorized_dag(method="GET", details=DagDetails(id="dag_0", team_name="team-a"), user=user) assert result is False assert "Keycloak authorization resource is missing; denying access" in caplog.text @@ -666,6 +669,7 @@ def test_filter_authorized_dag_ids_team_match(self, mock_is_authorized, auth_man assert result == {"dag-a"} @pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="team_name not supported before Airflow 3.2.0") + @conf_vars({("core", "multi_team"): "True"}) def test_filter_authorized_dag_ids_missing_keycloak_resource(self, auth_manager_multi_team, user, caplog): def post_response(*_, data, **__): claims = json.loads(base64.b64decode(data["claim_token"]).decode()) @@ -673,7 +677,7 @@ def post_response(*_, data, **__): response = Mock() if dag_id == "dag-missing": response.status_code = 500 - response.text = "resource not found: Dag:team-a" + response.text = "Resource with id [Dag:team-a] does not exist." else: response.status_code = 200 return response From 24b997b5bdc81c7fae59779a06d0a1ff540d73db Mon Sep 17 00:00:00 2001 From: Stephen Bracken Date: Fri, 26 Jun 2026 13:31:50 +0100 Subject: [PATCH 4/6] fix linting --- providers/keycloak/docs/auth-manager/index.rst | 2 +- .../keycloak/auth_manager/keycloak_auth_manager.py | 10 ++++++++-- .../auth_manager/test_keycloak_auth_manager.py | 8 ++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/providers/keycloak/docs/auth-manager/index.rst b/providers/keycloak/docs/auth-manager/index.rst index cf7d0a1acb59c..a07534c306903 100644 --- a/providers/keycloak/docs/auth-manager/index.rst +++ b/providers/keycloak/docs/auth-manager/index.rst @@ -60,4 +60,4 @@ When using the Keycloak auth manager with a multi-team deployment, you will need If a team has dags added to Airflow but they are not setup in the keycloak client, keycloak will return a 500 error on the ``/dags`` screen: ```Resource with id [Dag:] does not exist.``` -The keycloak auth manager will handle this error by registering it as a 'deny' response to preserve access to the ``/dags`` screen for other teams. \ No newline at end of file +The keycloak auth manager will handle this error by registering it as a 'deny' response to preserve access to the ``/dags`` screen for other teams. diff --git a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py index 94a68d0868ca1..ec68b6126216e 100644 --- a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py +++ b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py @@ -82,7 +82,11 @@ def _is_missing_keycloak_resource_response(resource_name: str, status_code: int, text: Any) -> bool: - return status_code == 500 and isinstance(text, str) and KEYCLOAK_RESOURCE_NOT_FOUND_ERROR.format(resource_name=resource_name).lower() in text.lower() + return ( + status_code == 500 + and isinstance(text, str) + and KEYCLOAK_RESOURCE_NOT_FOUND_ERROR.format(resource_name=resource_name).lower() in text.lower() + ) TEAM_SCOPED_RESOURCES = frozenset( @@ -444,7 +448,9 @@ def _is_authorized( raise AirflowException( f"Request not recognized by Keycloak. {error.get('error')}. {error.get('error_description')}" ) - if is_team_resource and _is_missing_keycloak_resource_response(resource_name, resp.status_code, resp.text): + if is_team_resource and _is_missing_keycloak_resource_response( + resource_name, resp.status_code, resp.text + ): log.warning("Keycloak authorization resource is missing; denying access. Response: %s", resp.text) return False raise AirflowException(f"Unexpected error: {resp.status_code} - {resp.text}") diff --git a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py index 54b882ac46640..eaac048aaede4 100644 --- a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py +++ b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py @@ -38,8 +38,8 @@ VariableDetails, ) -from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_7_PLUS, AIRFLOW_V_3_2_PLUS from tests_common.test_utils.config import conf_vars +from tests_common.test_utils.version_compat import AIRFLOW_V_3_1_7_PLUS, AIRFLOW_V_3_2_PLUS if AIRFLOW_V_3_2_PLUS: from airflow.api_fastapi.auth.managers.models.resource_details import TeamDetails @@ -66,8 +66,6 @@ ) from airflow.providers.keycloak.auth_manager.user import KeycloakAuthManagerUser -from tests_common.test_utils.config import conf_vars - def _build_access_token(payload: dict[str, object]) -> str: header = {"alg": "none", "typ": "JWT"} @@ -396,7 +394,9 @@ def test_is_authorized_missing_keycloak_resource(self, auth_manager, user, caplo auth_manager.http_session.post = Mock(return_value=resp) caplog.set_level("WARNING", logger="airflow.providers.keycloak.auth_manager.keycloak_auth_manager") - result = auth_manager.is_authorized_dag(method="GET", details=DagDetails(id="dag_0", team_name="team-a"), user=user) + result = auth_manager.is_authorized_dag( + method="GET", details=DagDetails(id="dag_0", team_name="team-a"), user=user + ) assert result is False assert "Keycloak authorization resource is missing; denying access" in caplog.text From 474b647ce107b3251c1d67327011c60c6489dbf5 Mon Sep 17 00:00:00 2001 From: Stephen Bracken Date: Fri, 26 Jun 2026 17:25:41 +0100 Subject: [PATCH 5/6] change error check to use keycloak 'error' response field --- .../auth_manager/keycloak_auth_manager.py | 21 +++++++------------ .../test_keycloak_auth_manager.py | 4 ++-- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py index ec68b6126216e..aa99b84cfaf00 100644 --- a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py +++ b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py @@ -78,15 +78,6 @@ log = logging.getLogger(__name__) RESOURCE_ID_ATTRIBUTE_NAME = "resource_id" -KEYCLOAK_RESOURCE_NOT_FOUND_ERROR = "resource with id [{resource_name}] does not exist" - - -def _is_missing_keycloak_resource_response(resource_name: str, status_code: int, text: Any) -> bool: - return ( - status_code == 500 - and isinstance(text, str) - and KEYCLOAK_RESOURCE_NOT_FOUND_ERROR.format(resource_name=resource_name).lower() in text.lower() - ) TEAM_SCOPED_RESOURCES = frozenset( @@ -448,11 +439,13 @@ def _is_authorized( raise AirflowException( f"Request not recognized by Keycloak. {error.get('error')}. {error.get('error_description')}" ) - if is_team_resource and _is_missing_keycloak_resource_response( - resource_name, resp.status_code, resp.text - ): - log.warning("Keycloak authorization resource is missing; denying access. Response: %s", resp.text) - return False + if resp.status_code == 500 and is_team_resource: + error = json.loads(resp.text) + if error.get("error") == "invalid_resource": + log.warning( + "Keycloak authorization resource is missing; denying access. Response: %s", resp.text + ) + return False raise AirflowException(f"Unexpected error: {resp.status_code} - {resp.text}") def filter_authorized_dag_ids( diff --git a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py index eaac048aaede4..eaaa0cf8dc1c9 100644 --- a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py +++ b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py @@ -390,7 +390,7 @@ def test_is_authorized_failure(self, function, auth_manager, user): def test_is_authorized_missing_keycloak_resource(self, auth_manager, user, caplog): resp = Mock() resp.status_code = 500 - resp.text = "Resource with id [Dag:team-a] does not exist." + resp.text = '{"error": "invalid_resource", "error_description": "Resource with id [Dag:team-a] does not exist."}' auth_manager.http_session.post = Mock(return_value=resp) caplog.set_level("WARNING", logger="airflow.providers.keycloak.auth_manager.keycloak_auth_manager") @@ -677,7 +677,7 @@ def post_response(*_, data, **__): response = Mock() if dag_id == "dag-missing": response.status_code = 500 - response.text = "Resource with id [Dag:team-a] does not exist." + response.text = '{"error":"invalid_resource", "error_description": "Resource with id [Dag:team-a] does not exist."}' else: response.status_code = 200 return response From 46b767d5bdd79d45e624e3db51c12d27150c9e6e Mon Sep 17 00:00:00 2001 From: Stephen Bracken Date: Sat, 27 Jun 2026 12:45:18 +0100 Subject: [PATCH 6/6] catch 400 error from keycloak --- providers/keycloak/docs/auth-manager/index.rst | 4 ++-- .../keycloak/auth_manager/keycloak_auth_manager.py | 11 +++++------ .../auth_manager/test_keycloak_auth_manager.py | 4 ++-- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/providers/keycloak/docs/auth-manager/index.rst b/providers/keycloak/docs/auth-manager/index.rst index a07534c306903..dc967c4b39563 100644 --- a/providers/keycloak/docs/auth-manager/index.rst +++ b/providers/keycloak/docs/auth-manager/index.rst @@ -58,6 +58,6 @@ It enables you to manage users, roles, groups, and permissions entirely within K **Note on Multi Team Clients** When using the Keycloak auth manager with a multi-team deployment, you will need to keep the keycloak client up to date by creating the required policies and resources for each new team that is added. -If a team has dags added to Airflow but they are not setup in the keycloak client, keycloak will return a 500 error on the ``/dags`` screen: -```Resource with id [Dag:] does not exist.``` +If a team has dags added to Airflow but they are not setup in the keycloak client, keycloak will return a 400 error on the ``/dags`` screen: +``{"error":"invalid_resource", "error_description": "Resource with id [Dag:team-a] does not exist."}`` The keycloak auth manager will handle this error by registering it as a 'deny' response to preserve access to the ``/dags`` screen for other teams. diff --git a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py index aa99b84cfaf00..64f12603ce6ef 100644 --- a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py +++ b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/keycloak_auth_manager.py @@ -436,16 +436,15 @@ def _is_authorized( return False if resp.status_code == 400: error = json.loads(resp.text) - raise AirflowException( - f"Request not recognized by Keycloak. {error.get('error')}. {error.get('error_description')}" - ) - if resp.status_code == 500 and is_team_resource: - error = json.loads(resp.text) - if error.get("error") == "invalid_resource": + if is_team_resource and error.get("error") == "invalid_resource": + # filter_authorized_dag_ids will return this error if team resources have not been added to the Keycloak Client. log.warning( "Keycloak authorization resource is missing; denying access. Response: %s", resp.text ) return False + raise AirflowException( + f"Request not recognized by Keycloak. {error.get('error')}. {error.get('error_description')}" + ) raise AirflowException(f"Unexpected error: {resp.status_code} - {resp.text}") def filter_authorized_dag_ids( diff --git a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py index eaaa0cf8dc1c9..48791d11d38c3 100644 --- a/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py +++ b/providers/keycloak/tests/unit/keycloak/auth_manager/test_keycloak_auth_manager.py @@ -389,7 +389,7 @@ def test_is_authorized_failure(self, function, auth_manager, user): @conf_vars({("core", "multi_team"): "True"}) def test_is_authorized_missing_keycloak_resource(self, auth_manager, user, caplog): resp = Mock() - resp.status_code = 500 + resp.status_code = 400 resp.text = '{"error": "invalid_resource", "error_description": "Resource with id [Dag:team-a] does not exist."}' auth_manager.http_session.post = Mock(return_value=resp) caplog.set_level("WARNING", logger="airflow.providers.keycloak.auth_manager.keycloak_auth_manager") @@ -676,7 +676,7 @@ def post_response(*_, data, **__): dag_id = claims[RESOURCE_ID_ATTRIBUTE_NAME][0] response = Mock() if dag_id == "dag-missing": - response.status_code = 500 + response.status_code = 400 response.text = '{"error":"invalid_resource", "error_description": "Resource with id [Dag:team-a] does not exist."}' else: response.status_code = 200