|
11 | 11 |
|
12 | 12 | import pytest |
13 | 13 | from airflow.api_fastapi.auth.managers.models.resource_details import ( |
| 14 | + AccessView, |
14 | 15 | DagAccessEntity, |
15 | 16 | DagDetails, |
16 | 17 | ) |
| 18 | +from airflow.api_fastapi.common.types import MenuItem |
17 | 19 | from airflow.providers.fab.www.extensions.init_appbuilder import init_appbuilder |
18 | 20 | from airflow.providers.fab.www.security.permissions import ( |
19 | 21 | ACTION_CAN_CREATE, |
@@ -56,6 +58,17 @@ def auth_manager_with_appbuilder(flask_app): |
56 | 58 |
|
57 | 59 |
|
58 | 60 | 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 | + |
59 | 72 | @pytest.mark.parametrize( |
60 | 73 | "method, dag_access_entity, dag_details, user_permissions, expected_opa_result, expected_result", |
61 | 74 | [ |
@@ -228,3 +241,153 @@ def test_is_authorized_dag( |
228 | 241 | user=user, |
229 | 242 | ) |
230 | 243 | 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