66# Then we could run these tests against the Airflow instance and use the Airflow API to
77# actually test the effect of Rego policies on user authorization.
88#
9+ from types import SimpleNamespace
910from unittest import mock
1011from unittest .mock import Mock
1112
1213import pytest
1314from airflow .api_fastapi .auth .managers .models .resource_details import (
14- AccessView ,
1515 DagAccessEntity ,
1616 DagDetails ,
1717)
@@ -57,6 +57,40 @@ def auth_manager_with_appbuilder(flask_app):
5757 return auth_manager
5858
5959
60+ @pytest .fixture
61+ def mock_opa (monkeypatch ):
62+ """
63+ Replace the OPA HTTP boundary (``call_opa``) so tests exercise the real
64+ ``is_authorized_*`` → ``_is_authorized_in_opa`` → ``OpaInput`` chain
65+ without making network calls.
66+
67+ Set ``mock_opa.decide = lambda endpoint, body: bool`` to drive per-request
68+ decisions. Default returns ``False`` (deny). Recorded ``(endpoint, body)``
69+ pairs are available as ``mock_opa.calls``.
70+ """
71+ state = SimpleNamespace (
72+ decide = lambda endpoint , body : False ,
73+ calls = [],
74+ )
75+
76+ def fake_call_opa (self , url , json , timeout ):
77+ endpoint = url .rsplit ("/" , 1 )[- 1 ]
78+ state .calls .append ((endpoint , json ))
79+ response = Mock ()
80+ response .json .return_value = {"result" : state .decide (endpoint , json )}
81+ return response
82+
83+ monkeypatch .setattr (OpaFabAuthManager , "call_opa" , fake_call_opa )
84+ return state
85+
86+
87+ def _make_user (name = "jane.doe" , user_id = "1" ):
88+ user = Mock ()
89+ user .get_id .return_value = user_id
90+ user .get_name .return_value = name
91+ return user
92+
93+
6094class TestOpaFabAuthManager :
6195 def test_init_wires_opa_cache_for_fastapi_apiserver (self ):
6296 # The FastAPI api-server calls auth_manager.init() instead of
@@ -243,53 +277,51 @@ def test_is_authorized_dag(
243277 assert result == expected_result
244278
245279 def test_get_authorized_dag_ids_uses_opa_not_fab_db (
246- self , auth_manager_with_appbuilder
280+ self , auth_manager_with_appbuilder , mock_opa
247281 ):
248282 # Repro for the OPA listing bug: a user with no FAB permissions
249283 # (e.g. the default Public role) must still see the DAGs that OPA
250284 # allows. The FabAuthManager base override would return set() here
251285 # because it reads roles from the metadata DB.
252- user = Mock ()
253- user .id = 1
254- user .perms = []
286+ user = _make_user ()
255287
256- all_dag_ids = {"allowed_dag" , "denied_dag" }
257288 session = Mock ()
258- session .execute .return_value = [Mock (dag_id = dag_id ) for dag_id in all_dag_ids ]
289+ session .execute .return_value = [
290+ Mock (dag_id = "allowed_dag" ),
291+ Mock (dag_id = "denied_dag" ),
292+ ]
259293
260- def fake_is_authorized_dag (* , method , details = None , access_entity = None , user ):
261- return details is not None and details .id == "allowed_dag"
294+ mock_opa .decide = lambda endpoint , body : (
295+ endpoint == "is_authorized_dag"
296+ and body ["input" ]["details" ]["id" ] == "allowed_dag"
297+ )
262298
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- )
299+ result = auth_manager_with_appbuilder .get_authorized_dag_ids (
300+ user = user , method = "GET" , session = session
301+ )
271302
272303 assert result == {"allowed_dag" }
304+ # Every DAG id was offered to OPA — confirms per-item delegation
305+ # rather than a global FAB role lookup.
306+ asked = {body ["input" ]["details" ]["id" ] for _ , body in mock_opa .calls }
307+ assert asked == {"allowed_dag" , "denied_dag" }
273308
274309 def test_get_authorized_dag_ids_provides_session_when_caller_omits_it (
275- self , auth_manager_with_appbuilder
310+ self , auth_manager_with_appbuilder , mock_opa
276311 ):
277312 # Real callers (api_fastapi/core_api/security.py) don't pass `session`.
278313 # Our override must rely on @provide_session to inject one; previously
279314 # it forwarded the default NEW_SESSION (None) and crashed with
280315 # 'NoneType' has no attribute 'execute'.
281- user = Mock ()
282- user .perms = []
316+ user = _make_user ()
283317
284318 session = Mock ()
285319 session .execute .return_value = [Mock (dag_id = "allowed_dag" )]
320+ mock_opa .decide = lambda endpoint , body : True
286321
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- ):
322+ with mock .patch (
323+ "airflow.utils.session.create_session"
324+ ) as mock_create_session :
293325 mock_create_session .return_value .__enter__ .return_value = session
294326 result = auth_manager_with_appbuilder .get_authorized_dag_ids (
295327 user = user , method = "GET"
@@ -299,95 +331,84 @@ def test_get_authorized_dag_ids_provides_session_when_caller_omits_it(
299331 mock_create_session .assert_called_once ()
300332
301333 @pytest .mark .parametrize (
302- "menu_item, expected_method, expected_kwargs " ,
334+ "menu_item, expected_endpoint, expected_input_subset " ,
303335 [
304336 (MenuItem .ASSETS , "is_authorized_asset" , {"method" : "GET" }),
305337 (
306338 MenuItem .AUDIT_LOG ,
307339 "is_authorized_dag" ,
308- {"method" : "GET" , "access_entity" : DagAccessEntity . AUDIT_LOG },
340+ {"method" : "GET" , "access_entity" : " AUDIT_LOG" },
309341 ),
310342 (MenuItem .CONFIG , "is_authorized_configuration" , {"method" : "GET" }),
311343 (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 }),
314344 (
315- MenuItem .PLUGINS ,
316- "is_authorized_view " ,
317- {"access_view " : AccessView . PLUGINS },
345+ MenuItem .DAGS ,
346+ "is_authorized_dag " ,
347+ {"method " : "GET" , "access_entity" : None },
318348 ),
349+ (MenuItem .DOCS , "is_authorized_view" , {"access_view" : "DOCS" }),
350+ (MenuItem .PLUGINS , "is_authorized_view" , {"access_view" : "PLUGINS" }),
319351 (MenuItem .POOLS , "is_authorized_pool" , {"method" : "GET" }),
320- (
321- MenuItem .PROVIDERS ,
322- "is_authorized_view" ,
323- {"access_view" : AccessView .PROVIDERS },
324- ),
352+ (MenuItem .PROVIDERS , "is_authorized_view" , {"access_view" : "PROVIDERS" }),
325353 (MenuItem .VARIABLES , "is_authorized_variable" , {"method" : "GET" }),
326354 (
327355 MenuItem .XCOMS ,
328356 "is_authorized_dag" ,
329- {"method" : "GET" , "access_entity" : DagAccessEntity . XCOM },
357+ {"method" : "GET" , "access_entity" : " XCOM" },
330358 ),
331359 ],
332360 )
333361 def test_filter_authorized_menu_items_routes_through_opa (
334362 self ,
335363 menu_item ,
336- expected_method ,
337- expected_kwargs ,
364+ expected_endpoint ,
365+ expected_input_subset ,
338366 auth_manager_with_appbuilder ,
367+ mock_opa ,
339368 ):
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 = []
369+ # Each MenuItem must trigger a request to the matching OPA endpoint
370+ # with the expected input , so menu visibility is OPA-driven rather
371+ # than FAB-DB-driven, and the Rego wire contract is documented.
372+ user = _make_user ()
344373
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- )
374+ mock_opa .decide = lambda endpoint , body : True
375+ allowed = auth_manager_with_appbuilder .filter_authorized_menu_items (
376+ [menu_item ], user = user
377+ )
351378 assert allowed == [menu_item ]
352- mocked .assert_called_once_with (user = user , ** expected_kwargs )
353379
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- )
380+ assert len (mock_opa .calls ) == 1
381+ endpoint , body = mock_opa .calls [0 ]
382+ assert endpoint == expected_endpoint
383+ assert expected_input_subset .items () <= body ["input" ].items ()
384+
385+ # Deny path: a fresh OPA decision actually filters the item out,
386+ # proving the dispatch consults OPA rather than always allowing.
387+ auth_manager_with_appbuilder .opa_cache .clear ()
388+ mock_opa .decide = lambda endpoint , body : False
389+ denied = auth_manager_with_appbuilder .filter_authorized_menu_items (
390+ [menu_item ], user = user
391+ )
360392 assert denied == []
361393
362394 def test_filter_authorized_menu_items_preserves_order_and_filters (
363- self , auth_manager_with_appbuilder
395+ self , auth_manager_with_appbuilder , mock_opa
364396 ):
365- user = Mock ()
366- user .perms = []
397+ user = _make_user ()
367398
368- def fake_is_authorized_dag (* , method , details = None , access_entity = None , user ):
369- return access_entity is None
399+ def decide (endpoint , body ):
400+ if endpoint == "is_authorized_dag" :
401+ # Allow DAGs root menu, deny the XCOM access entity.
402+ return body ["input" ].get ("access_entity" ) is None
403+ if endpoint == "is_authorized_connection" :
404+ return True
405+ return False
370406
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- )
407+ mock_opa .decide = decide
408+
409+ result = auth_manager_with_appbuilder .filter_authorized_menu_items (
410+ [MenuItem .DAGS , MenuItem .DOCS , MenuItem .CONNECTIONS , MenuItem .XCOMS ],
411+ user = user ,
412+ )
392413
393414 assert result == [MenuItem .DAGS , MenuItem .CONNECTIONS ]
0 commit comments