This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new a3abc237355 Check admin-only views against a dedicated Keycloak
resource in multi-team mode (#73696)
a3abc237355 is described below
commit a3abc237355d9db99cdcf6669e95bd42ded93064
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sun Sep 27 16:24:08 2026 +0200
Check admin-only views against a dedicated Keycloak resource in multi-team
mode (#73696)
* Check admin-only views against a dedicated Keycloak resource in
multi-team mode
The Keycloak auth manager checked every AccessView against the single
``View`` resource, and ``create-team`` grants ``ViewAccess`` (GET on
``View``) to every team role. That made the admin-by-default views --
``AUDIT_LOGS_ALL``, ``IMPORT_ERRORS_ALL`` and ``REPARSE_ALL``, which cover
records not tied to a Dag or a team -- readable by any team member, across
teams.
In multi-team mode these views are now checked against a new ``AdminView``
resource. The CLI creates it with an ``AdminViewAccess`` permission that is
granted to ``SuperAdmin`` only; team roles keep ``ViewAccess`` on ``View``
for the other views. A client that does not have the ``AdminView`` resource
yet denies these views instead of raising, until ``create-team`` is run
again. Without ``[core] multi_team`` the checks are unchanged.
Generated-by: Claude Opus 5 following the guidelines at
https://github.com/apache/airflow/blob/main/contributing-docs/05_pull_requests.rst#gen-ai-assisted-contributions
* Add reparsing to the spelling wordlist
Generated-by: Claude Opus 5
---
docs/spelling_wordlist.txt | 1 +
.../docs/auth-manager/manage/permissions.rst | 4 ++
providers/keycloak/docs/changelog.rst | 10 +++++
.../keycloak/auth_manager/cli/commands.py | 21 ++++++++++
.../keycloak/auth_manager/keycloak_auth_manager.py | 18 +++++++-
.../providers/keycloak/auth_manager/resources.py | 5 +++
.../keycloak/auth_manager/cli/test_commands.py | 35 ++++++++++++++--
.../auth_manager/test_keycloak_auth_manager.py | 48 ++++++++++++++++++++++
8 files changed, 137 insertions(+), 5 deletions(-)
diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index 03876053a19..c31da3d2400 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -1448,6 +1448,7 @@ renderers
renewer
reparse
reparsed
+reparsing
replicaSet
repo
repos
diff --git a/providers/keycloak/docs/auth-manager/manage/permissions.rst
b/providers/keycloak/docs/auth-manager/manage/permissions.rst
index 15b0b074079..ed323942cdb 100644
--- a/providers/keycloak/docs/auth-manager/manage/permissions.rst
+++ b/providers/keycloak/docs/auth-manager/manage/permissions.rst
@@ -119,6 +119,10 @@ Note: the CLI creates groups, resources, permissions, and
policies, but **does n
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.
+Views over records that are not tied to a Dag or a team -- audit log entries
not tied to a Dag,
+import errors for files with no registered Dag and reparsing such files -- are
checked against the
+``AdminView`` resource, which the CLI grants to ``SuperAdmin`` only. The other
views are checked against
+the ``View`` resource, which every team role can read.
More resources about permissions can be found in the official documentation of
Keycloak:
diff --git a/providers/keycloak/docs/changelog.rst
b/providers/keycloak/docs/changelog.rst
index 9494ec79bcc..891ad6d9193 100644
--- a/providers/keycloak/docs/changelog.rst
+++ b/providers/keycloak/docs/changelog.rst
@@ -25,6 +25,16 @@
Changelog
---------
+.. note::
+ In multi-team mode, the views covering records that are not tied to a Dag
or a team -- audit log
+ entries not tied to a Dag, import errors for files with no registered Dag
and reparsing such
+ files -- are now checked against a new ``AdminView`` Keycloak resource
instead of ``View``.
+ Only ``SuperAdmin`` is granted access to it; team roles keep access to the
other views through
+ ``View``. Until the Keycloak client is updated, access to these views is
denied to every user.
+ Run ``airflow keycloak-auth-manager create-team <team>`` again for an
existing team to create the
+ resource and the ``AdminViewAccess`` permission and grant it to
``SuperAdmin``. Deployments
+ without ``[core] multi_team`` are not affected.
+
0.11.0
......
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 ca1e8081dc9..84274b220e5 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
@@ -555,6 +555,14 @@ def _get_permissions_to_create(
"resources": [KeycloakResource.VIEW.value],
}
)
+ perm_configs.append(
+ {
+ "name": "AdminViewAccess",
+ "type": "scope-based",
+ "scope_names": ["GET"],
+ "resources": [KeycloakResource.ADMIN_VIEW.value],
+ }
+ )
perm_configs.append(
{
"name": "MenuAccess",
@@ -884,6 +892,9 @@ def _attach_team_permissions(
],
_dry_run=_dry_run,
)
+ # ``View`` only covers the views every team role may read. Views over
records that are not tied
+ # to a team (e.g. audit log rows not tied to a Dag) live on ``AdminView``,
which team roles do not
+ # get -- see ``_attach_superadmin_permissions``.
for role_name in TEAM_ROLE_NAMES:
_attach_policy_to_scope_permission(
client,
@@ -990,6 +1001,16 @@ def _attach_superadmin_permissions(
decision_strategy="AFFIRMATIVE",
_dry_run=_dry_run,
)
+ _attach_policy_to_scope_permission(
+ client,
+ client_uuid,
+ permission_name="AdminViewAccess",
+ policy_name=_role_policy_name(SUPER_ADMIN_ROLE_NAME),
+ scope_names=["GET"],
+ resource_names=[KeycloakResource.ADMIN_VIEW.value],
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=_dry_run,
+ )
_attach_policy_to_scope_permission(
client,
client_uuid,
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 1b8e6d7e630..c195170ca04 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
@@ -91,6 +91,10 @@ log = logging.getLogger(__name__)
RESOURCE_ID_ATTRIBUTE_NAME = "resource_id"
+# ``AccessView`` values covering records that carry no per-Dag or per-team key
to authorize on. They
+# are admin-by-default in core. Compared by value because older Airflow
versions lack some members.
+ADMIN_ACCESS_VIEW_NAMES = frozenset({"AUDIT_LOGS_ALL", "IMPORT_ERRORS_ALL",
"REPARSE_ALL"})
+
TEAM_SCOPED_RESOURCES = frozenset(
{
@@ -394,9 +398,16 @@ class
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
def is_authorized_view(
self, *, access_view: AccessView, user: KeycloakAuthManagerUser,
team_name: str | None = None
) -> bool:
+ resource_type = KeycloakResource.VIEW
+ if access_view.value in ADMIN_ACCESS_VIEW_NAMES and conf.getboolean(
+ "core", "multi_team", fallback=False
+ ):
+ # Every team role can read ``VIEW`` in multi-team mode, so these
views are checked
+ # against a separate resource that the CLI grants to
``SuperAdmin`` only.
+ resource_type = KeycloakResource.ADMIN_VIEW
return self._is_authorized(
method="GET",
- resource_type=KeycloakResource.VIEW,
+ resource_type=resource_type,
user=user,
resource_id=access_view.value,
team_name=team_name,
@@ -528,8 +539,11 @@ class
KeycloakAuthManager(BaseAuthManager[KeycloakAuthManagerUser]):
if error.get("error") == "invalid_grant":
log.debug("Received invalid_grant from Keycloak: %s",
resp.text)
return False
- if is_team_resource and error.get("error") == "invalid_resource":
+ if (is_team_resource or resource_type ==
KeycloakResource.ADMIN_VIEW) 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.
+ # The same applies to the ``AdminView`` resource on a client
provisioned by an older CLI.
log.warning(
"Keycloak authorization resource is missing; denying
access. Response: %s", resp.text
)
diff --git
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/resources.py
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/resources.py
index 0f9068e993e..9bc239fb084 100644
---
a/providers/keycloak/src/airflow/providers/keycloak/auth_manager/resources.py
+++
b/providers/keycloak/src/airflow/providers/keycloak/auth_manager/resources.py
@@ -22,6 +22,11 @@ from enum import Enum
class KeycloakResource(Enum):
"""Enum of Keycloak resources."""
+ # Views over records that carry no per-Dag or per-team key to authorize on
(audit log rows not
+ # tied to a Dag, import errors for files with no registered Dag, ...). In
multi-team mode they
+ # are checked against this resource rather than ``VIEW`` so that they are
not granted along
+ # with the views every team role can read.
+ ADMIN_VIEW = "AdminView"
ASSET = "Asset"
ASSET_ALIAS = "AssetAlias"
BACKFILL = "Backfill"
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 b0973573a0a..418db54d1ec 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
@@ -353,6 +353,7 @@ class TestCommands:
{"_id": "r10", "name": "Asset"},
{"_id": "r11", "name": "AssetAlias"},
{"_id": "r12", "name": "Configuration"},
+ {"_id": "r13", "name": "AdminView"},
]
client.get_clients.return_value = [
@@ -417,6 +418,17 @@ class TestCommands:
"resources": ["r5"],
},
)
+ client.create_client_authz_scope_permission.assert_any_call(
+ client_id="test-id",
+ payload={
+ "name": "AdminViewAccess",
+ "type": "scope",
+ "logic": "POSITIVE",
+ "decisionStrategy": "UNANIMOUS",
+ "scopes": ["1"],
+ "resources": ["r13"],
+ },
+ )
client.create_client_authz_scope_permission.assert_any_call(
client_id="test-id",
payload={
@@ -746,16 +758,33 @@ class TestCommands:
decision_strategy="AFFIRMATIVE",
_dry_run=False,
)
+ for role_name in TEAM_ROLE_NAMES:
+ mock_attach_policy.assert_any_call(
+ client,
+ "test-id",
+ permission_name="ViewAccess",
+ policy_name=f"Allow-{role_name}-team-a",
+ scope_names=["GET"],
+ resource_names=["View"],
+ decision_strategy="AFFIRMATIVE",
+ _dry_run=False,
+ )
mock_attach_policy.assert_any_call(
client,
"test-id",
- permission_name="ViewAccess",
- policy_name="Allow-Viewer-team-a",
+ permission_name="AdminViewAccess",
+ policy_name="Allow-SuperAdmin",
scope_names=["GET"],
- resource_names=["View"],
+ resource_names=["AdminView"],
decision_strategy="AFFIRMATIVE",
_dry_run=False,
)
+ admin_view_policies = [
+ c.kwargs["policy_name"]
+ for c in mock_attach_policy.call_args_list
+ if c.kwargs["permission_name"] == "AdminViewAccess" or "AdminView"
in c.kwargs["resource_names"]
+ ]
+ assert admin_view_policies == ["Allow-SuperAdmin"]
mock_ensure_scope_permission.assert_any_call(
client,
"test-id",
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 d4d3a192887..a129322ac58 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
@@ -1027,6 +1027,54 @@ class TestKeycloakAuthManager:
)
assert result == expected
+ @pytest.mark.parametrize(
+ ("view_name", "multi_team", "expected_permission"),
+ [
+ ("AUDIT_LOGS_ALL", "True", "AdminView#GET"),
+ ("IMPORT_ERRORS_ALL", "True", "AdminView#GET"),
+ ("REPARSE_ALL", "True", "AdminView#GET"),
+ ("AUDIT_LOGS_ALL", "False", "View#GET"),
+ ("IMPORT_ERRORS", "True", "View#GET"),
+ ("PLUGINS", "True", "View#GET"),
+ ],
+ )
+ def test_is_authorized_view_resource(
+ self, view_name, multi_team, expected_permission, auth_manager, user
+ ):
+ access_view = getattr(AccessView, view_name, None)
+ if access_view is None:
+ pytest.skip(f"AccessView.{view_name} is not available in this
Airflow version")
+ mock_response = Mock()
+ mock_response.status_code = 200
+ auth_manager.http_session.post = Mock(return_value=mock_response)
+
+ with conf_vars({("core", "multi_team"): multi_team}):
+ assert auth_manager.is_authorized_view(access_view=access_view,
user=user) is True
+
+ payload = auth_manager._get_payload(
+ "client_id", expected_permission, {RESOURCE_ID_ATTRIBUTE_NAME:
view_name}
+ )
+ auth_manager.http_session.post.assert_called_once_with(
+ auth_manager._get_token_url("server_url", "realm"),
+ data=payload,
+ headers=auth_manager._get_headers(user.access_token),
+ timeout=5,
+ )
+
+ @pytest.mark.skipif(not AIRFLOW_V_3_4_PLUS,
reason="AccessView.AUDIT_LOGS_ALL not available")
+ @conf_vars({("core", "multi_team"): "True"})
+ def test_is_authorized_view_missing_admin_view_resource(self,
auth_manager, user, caplog):
+ resp = Mock()
+ resp.status_code = 400
+ resp.text = '{"error": "invalid_resource", "error_description":
"Resource with id [AdminView] 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")
+
+ result =
auth_manager.is_authorized_view(access_view=AccessView.AUDIT_LOGS_ALL,
user=user)
+
+ assert result is False
+ assert "Keycloak authorization resource is missing; denying access" in
caplog.text
+
@pytest.mark.parametrize(
("status_code", "expected"),
[