Skip to content

Commit 46ef412

Browse files
committed
fix: Support OPA for Airflow when user role is not 'Admin'
1 parent 959101d commit 46ef412

3 files changed

Lines changed: 254 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ All notable changes to this project will be documented in this file.
2424
- vector: Look for SBOM in correct location ([#1471]).
2525
- vector: Use correct license ([#1476]).
2626
- trino: Build a patched Airlift from source and depend on it to backport [airlift/airlift#1943](https://github.com/airlift/airlift/pull/1943), applying the configured max response header size to Jetty's `maxResponseHeaderSize` ([#1510]).
27+
- airflow: Route DAG listings and menu items through OPA in the Airflow 3 OPA auth manager, and wire the OPA cache on the FastAPI api-server init path ([#XXXX]).
2728

2829
### Removed
2930

airflow/opa-auth-manager/airflow-3/opa_auth_manager/opa_fab_auth_manager.py

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,18 @@
1919
PoolDetails,
2020
VariableDetails,
2121
)
22+
from airflow.api_fastapi.common.types import MenuItem
2223
from airflow.configuration import conf
24+
from airflow.models.dag import DagModel
2325
from airflow.providers.fab.auth_manager.fab_auth_manager import FabAuthManager
2426
from airflow.providers.fab.auth_manager.models import User
2527
from airflow.stats import Stats
2628
from airflow.utils.log.logging_mixin import LoggingMixin
29+
from airflow.utils.session import NEW_SESSION, provide_session
2730
from cachetools import TTLCache, cachedmethod
2831
from overrides import override
32+
from sqlalchemy import select
33+
from sqlalchemy.orm import Session
2934

3035
METRIC_NAME_OPA_CACHE_LIMIT_REACHED = "opa_cache_limit_reached"
3136

@@ -75,12 +80,36 @@ class OpaFabAuthManager(FabAuthManager, LoggingMixin):
7580
AUTH_OPA_REQUEST_TIMEOUT_DEFAULT = 10
7681

7782
@override
78-
def init_flask_resources(self) -> None:
83+
def init(self) -> None:
7984
"""
8085
Run operations when Airflow is initializing.
86+
87+
Called by the FastAPI api-server during startup.
88+
"""
89+
90+
super().init()
91+
self._init_opa_resources()
92+
93+
@override
94+
def init_flask_resources(self) -> None:
95+
"""
96+
Run operations when the Flask app (FAB UI) is initializing.
8197
"""
8298

8399
super().init_flask_resources()
100+
self._init_opa_resources()
101+
102+
def _init_opa_resources(self) -> None:
103+
"""
104+
Set up the OPA cache and HTTP session.
105+
106+
Idempotent so it can be called from both ``init`` (FastAPI api-server)
107+
and ``init_flask_resources`` (Flask AppBuilder) — only one of the two
108+
runs depending on the Airflow component.
109+
"""
110+
111+
if getattr(self, "opa_cache", None) is not None:
112+
return
84113

85114
Stats.incr(METRIC_NAME_OPA_CACHE_LIMIT_REACHED, count=0)
86115

@@ -121,9 +150,6 @@ def _is_authorized_in_opa(self, endpoint: str, input: OpaInput) -> bool:
121150

122151
self.log.debug("Forward authorization request to OPA")
123152

124-
opa_url = conf.get(
125-
"core", "AUTH_OPA_REQUEST_URL", fallback=self.AUTH_OPA_REQUEST_URL_DEFAULT
126-
)
127153
opa_url = conf.get(
128154
"core", "AUTH_OPA_REQUEST_URL", fallback=self.AUTH_OPA_REQUEST_URL_DEFAULT
129155
)
@@ -551,3 +577,63 @@ def is_authorized_custom_view(
551577
}
552578
),
553579
)
580+
581+
@provide_session
582+
@override
583+
def get_authorized_dag_ids(
584+
self,
585+
*,
586+
user: User,
587+
method: ResourceMethod = "GET",
588+
session: Session = NEW_SESSION,
589+
) -> set[str]:
590+
# FabAuthManager's implementation consults the user's FAB DB role
591+
# permissions and bypasses is_authorized_dag entirely, which makes any
592+
# user without a FAB role (e.g. the default Public role) see an empty
593+
# DAG list even when OPA would allow them. Re-implement the
594+
# BaseAuthManager default: list all DAG ids and filter them via
595+
# is_authorized_dag → OPA.
596+
dag_ids = {dag.dag_id for dag in session.execute(select(DagModel.dag_id))}
597+
return self.filter_authorized_dag_ids(dag_ids=dag_ids, method=method, user=user)
598+
599+
@override
600+
def filter_authorized_menu_items(
601+
self, menu_items: list[MenuItem], user: User
602+
) -> list[MenuItem]:
603+
# FabAuthManager filters menu items via the FAB role permissions in the
604+
# DB, which yields an empty menu for users without FAB perms even when
605+
# OPA grants access. Route each menu item through the matching
606+
# OPA-backed is_authorized_* call instead.
607+
return [
608+
item for item in menu_items if self._is_menu_item_authorized(item, user)
609+
]
610+
611+
def _is_menu_item_authorized(self, menu_item: MenuItem, user: User) -> bool:
612+
if menu_item == MenuItem.ASSETS:
613+
return self.is_authorized_asset(method="GET", user=user)
614+
if menu_item == MenuItem.AUDIT_LOG:
615+
return self.is_authorized_dag(
616+
method="GET", access_entity=DagAccessEntity.AUDIT_LOG, user=user
617+
)
618+
if menu_item == MenuItem.CONFIG:
619+
return self.is_authorized_configuration(method="GET", user=user)
620+
if menu_item == MenuItem.CONNECTIONS:
621+
return self.is_authorized_connection(method="GET", user=user)
622+
if menu_item == MenuItem.DAGS:
623+
return self.is_authorized_dag(method="GET", user=user)
624+
if menu_item == MenuItem.DOCS:
625+
return self.is_authorized_view(access_view=AccessView.DOCS, user=user)
626+
if menu_item == MenuItem.PLUGINS:
627+
return self.is_authorized_view(access_view=AccessView.PLUGINS, user=user)
628+
if menu_item == MenuItem.POOLS:
629+
return self.is_authorized_pool(method="GET", user=user)
630+
if menu_item == MenuItem.PROVIDERS:
631+
return self.is_authorized_view(access_view=AccessView.PROVIDERS, user=user)
632+
if menu_item == MenuItem.VARIABLES:
633+
return self.is_authorized_variable(method="GET", user=user)
634+
if menu_item == MenuItem.XCOMS:
635+
return self.is_authorized_dag(
636+
method="GET", access_entity=DagAccessEntity.XCOM, user=user
637+
)
638+
self.log.warning("Unknown menu item %s — denying", menu_item)
639+
return False

airflow/opa-auth-manager/airflow-3/tests/test_opa_fab_auth_manager.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111

1212
import pytest
1313
from airflow.api_fastapi.auth.managers.models.resource_details import (
14+
AccessView,
1415
DagAccessEntity,
1516
DagDetails,
1617
)
18+
from airflow.api_fastapi.common.types import MenuItem
1719
from airflow.providers.fab.www.extensions.init_appbuilder import init_appbuilder
1820
from airflow.providers.fab.www.security.permissions import (
1921
ACTION_CAN_CREATE,
@@ -56,6 +58,17 @@ def auth_manager_with_appbuilder(flask_app):
5658

5759

5860
class TestOpaFabAuthManager:
61+
def test_init_wires_opa_cache_for_fastapi_apiserver(self):
62+
# The FastAPI api-server calls auth_manager.init() instead of
63+
# init_flask_resources(). Without wiring the cache from init() too,
64+
# any is_authorized_* call from a REST handler crashes with
65+
# AttributeError: 'OpaFabAuthManager' object has no attribute 'opa_cache'.
66+
auth_manager = OpaFabAuthManager()
67+
auth_manager.init()
68+
69+
assert auth_manager.opa_cache is not None
70+
assert auth_manager.opa_session is not None
71+
5972
@pytest.mark.parametrize(
6073
"method, dag_access_entity, dag_details, user_permissions, expected_opa_result, expected_result",
6174
[
@@ -228,3 +241,153 @@ def test_is_authorized_dag(
228241
user=user,
229242
)
230243
assert result == expected_result
244+
245+
def test_get_authorized_dag_ids_uses_opa_not_fab_db(
246+
self, auth_manager_with_appbuilder
247+
):
248+
# Repro for the OPA listing bug: a user with no FAB permissions
249+
# (e.g. the default Public role) must still see the DAGs that OPA
250+
# allows. The FabAuthManager base override would return set() here
251+
# because it reads roles from the metadata DB.
252+
user = Mock()
253+
user.id = 1
254+
user.perms = []
255+
256+
all_dag_ids = {"allowed_dag", "denied_dag"}
257+
session = Mock()
258+
session.execute.return_value = [Mock(dag_id=dag_id) for dag_id in all_dag_ids]
259+
260+
def fake_is_authorized_dag(*, method, details=None, access_entity=None, user):
261+
return details is not None and details.id == "allowed_dag"
262+
263+
with mock.patch.object(
264+
auth_manager_with_appbuilder,
265+
"is_authorized_dag",
266+
side_effect=fake_is_authorized_dag,
267+
):
268+
result = auth_manager_with_appbuilder.get_authorized_dag_ids(
269+
user=user, method="GET", session=session
270+
)
271+
272+
assert result == {"allowed_dag"}
273+
274+
def test_get_authorized_dag_ids_provides_session_when_caller_omits_it(
275+
self, auth_manager_with_appbuilder
276+
):
277+
# Real callers (api_fastapi/core_api/security.py) don't pass `session`.
278+
# Our override must rely on @provide_session to inject one; previously
279+
# it forwarded the default NEW_SESSION (None) and crashed with
280+
# 'NoneType' has no attribute 'execute'.
281+
user = Mock()
282+
user.perms = []
283+
284+
session = Mock()
285+
session.execute.return_value = [Mock(dag_id="allowed_dag")]
286+
287+
with (
288+
mock.patch("airflow.utils.session.create_session") as mock_create_session,
289+
mock.patch.object(
290+
auth_manager_with_appbuilder, "is_authorized_dag", return_value=True
291+
),
292+
):
293+
mock_create_session.return_value.__enter__.return_value = session
294+
result = auth_manager_with_appbuilder.get_authorized_dag_ids(
295+
user=user, method="GET"
296+
)
297+
298+
assert result == {"allowed_dag"}
299+
mock_create_session.assert_called_once()
300+
301+
@pytest.mark.parametrize(
302+
"menu_item, expected_method, expected_kwargs",
303+
[
304+
(MenuItem.ASSETS, "is_authorized_asset", {"method": "GET"}),
305+
(
306+
MenuItem.AUDIT_LOG,
307+
"is_authorized_dag",
308+
{"method": "GET", "access_entity": DagAccessEntity.AUDIT_LOG},
309+
),
310+
(MenuItem.CONFIG, "is_authorized_configuration", {"method": "GET"}),
311+
(MenuItem.CONNECTIONS, "is_authorized_connection", {"method": "GET"}),
312+
(MenuItem.DAGS, "is_authorized_dag", {"method": "GET"}),
313+
(MenuItem.DOCS, "is_authorized_view", {"access_view": AccessView.DOCS}),
314+
(
315+
MenuItem.PLUGINS,
316+
"is_authorized_view",
317+
{"access_view": AccessView.PLUGINS},
318+
),
319+
(MenuItem.POOLS, "is_authorized_pool", {"method": "GET"}),
320+
(
321+
MenuItem.PROVIDERS,
322+
"is_authorized_view",
323+
{"access_view": AccessView.PROVIDERS},
324+
),
325+
(MenuItem.VARIABLES, "is_authorized_variable", {"method": "GET"}),
326+
(
327+
MenuItem.XCOMS,
328+
"is_authorized_dag",
329+
{"method": "GET", "access_entity": DagAccessEntity.XCOM},
330+
),
331+
],
332+
)
333+
def test_filter_authorized_menu_items_routes_through_opa(
334+
self,
335+
menu_item,
336+
expected_method,
337+
expected_kwargs,
338+
auth_manager_with_appbuilder,
339+
):
340+
# Each MenuItem must trigger the matching OPA-backed is_authorized_*
341+
# call, so menu visibility is OPA-driven rather than FAB-DB-driven.
342+
user = Mock()
343+
user.perms = []
344+
345+
with mock.patch.object(
346+
auth_manager_with_appbuilder, expected_method, return_value=True
347+
) as mocked:
348+
allowed = auth_manager_with_appbuilder.filter_authorized_menu_items(
349+
[menu_item], user=user
350+
)
351+
assert allowed == [menu_item]
352+
mocked.assert_called_once_with(user=user, **expected_kwargs)
353+
354+
with mock.patch.object(
355+
auth_manager_with_appbuilder, expected_method, return_value=False
356+
):
357+
denied = auth_manager_with_appbuilder.filter_authorized_menu_items(
358+
[menu_item], user=user
359+
)
360+
assert denied == []
361+
362+
def test_filter_authorized_menu_items_preserves_order_and_filters(
363+
self, auth_manager_with_appbuilder
364+
):
365+
user = Mock()
366+
user.perms = []
367+
368+
def fake_is_authorized_dag(*, method, details=None, access_entity=None, user):
369+
return access_entity is None
370+
371+
with (
372+
mock.patch.object(
373+
auth_manager_with_appbuilder,
374+
"is_authorized_dag",
375+
side_effect=fake_is_authorized_dag,
376+
),
377+
mock.patch.object(
378+
auth_manager_with_appbuilder,
379+
"is_authorized_connection",
380+
return_value=True,
381+
),
382+
mock.patch.object(
383+
auth_manager_with_appbuilder,
384+
"is_authorized_view",
385+
return_value=False,
386+
),
387+
):
388+
result = auth_manager_with_appbuilder.filter_authorized_menu_items(
389+
[MenuItem.DAGS, MenuItem.DOCS, MenuItem.CONNECTIONS, MenuItem.XCOMS],
390+
user=user,
391+
)
392+
393+
assert result == [MenuItem.DAGS, MenuItem.CONNECTIONS]

0 commit comments

Comments
 (0)