|
6 | 6 | # Then we could run these tests against the Airflow instance and use the Airflow API to |
7 | 7 | # actually test the effect of Rego policies on user authorization. |
8 | 8 | # |
| 9 | +from types import SimpleNamespace |
9 | 10 | from unittest import mock |
10 | 11 | from unittest.mock import Mock |
11 | 12 |
|
|
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, |
@@ -55,7 +57,52 @@ def auth_manager_with_appbuilder(flask_app): |
55 | 57 | return auth_manager |
56 | 58 |
|
57 | 59 |
|
| 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 | + |
58 | 94 | class TestOpaFabAuthManager: |
| 95 | + def test_init_wires_opa_cache_for_fastapi_apiserver(self): |
| 96 | + # The FastAPI api-server calls auth_manager.init() instead of |
| 97 | + # init_flask_resources(). Without wiring the cache from init() too, |
| 98 | + # any is_authorized_* call from a REST handler crashes with |
| 99 | + # AttributeError: 'OpaFabAuthManager' object has no attribute 'opa_cache'. |
| 100 | + auth_manager = OpaFabAuthManager() |
| 101 | + auth_manager.init() |
| 102 | + |
| 103 | + assert auth_manager.opa_cache is not None |
| 104 | + assert auth_manager.opa_session is not None |
| 105 | + |
59 | 106 | @pytest.mark.parametrize( |
60 | 107 | "method, dag_access_entity, dag_details, user_permissions, expected_opa_result, expected_result", |
61 | 108 | [ |
@@ -228,3 +275,155 @@ def test_is_authorized_dag( |
228 | 275 | user=user, |
229 | 276 | ) |
230 | 277 | assert result == expected_result |
| 278 | + |
| 279 | + def test_get_authorized_dag_ids_uses_opa_not_fab_db( |
| 280 | + self, auth_manager_with_appbuilder, mock_opa |
| 281 | + ): |
| 282 | + # Repro for the OPA listing bug: a user with no FAB permissions |
| 283 | + # (e.g. the default Public role) must still see the DAGs that OPA |
| 284 | + # allows. The FabAuthManager base override would return set() here |
| 285 | + # because it reads roles from the metadata DB. |
| 286 | + user = _make_user() |
| 287 | + |
| 288 | + session = Mock() |
| 289 | + session.execute.return_value = [ |
| 290 | + Mock(dag_id="allowed_dag"), |
| 291 | + Mock(dag_id="denied_dag"), |
| 292 | + ] |
| 293 | + |
| 294 | + mock_opa.decide = lambda endpoint, body: ( |
| 295 | + endpoint == "is_authorized_dag" |
| 296 | + and body["input"]["details"]["id"] == "allowed_dag" |
| 297 | + ) |
| 298 | + |
| 299 | + result = auth_manager_with_appbuilder.get_authorized_dag_ids( |
| 300 | + user=user, method="GET", session=session |
| 301 | + ) |
| 302 | + |
| 303 | + 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"} |
| 308 | + |
| 309 | + def test_get_authorized_dag_ids_provides_session_when_caller_omits_it( |
| 310 | + self, auth_manager_with_appbuilder, mock_opa |
| 311 | + ): |
| 312 | + # Real callers (api_fastapi/core_api/security.py) don't pass `session`. |
| 313 | + # Our override must rely on @provide_session to inject one; previously |
| 314 | + # it forwarded the default NEW_SESSION (None) and crashed with |
| 315 | + # 'NoneType' has no attribute 'execute'. |
| 316 | + user = _make_user() |
| 317 | + |
| 318 | + session = Mock() |
| 319 | + session.execute.return_value = [Mock(dag_id="allowed_dag")] |
| 320 | + mock_opa.decide = lambda endpoint, body: True |
| 321 | + |
| 322 | + with mock.patch("airflow.utils.session.create_session") as mock_create_session: |
| 323 | + mock_create_session.return_value.__enter__.return_value = session |
| 324 | + result = auth_manager_with_appbuilder.get_authorized_dag_ids( |
| 325 | + user=user, method="GET" |
| 326 | + ) |
| 327 | + |
| 328 | + assert result == {"allowed_dag"} |
| 329 | + mock_create_session.assert_called_once() |
| 330 | + |
| 331 | + @pytest.mark.parametrize( |
| 332 | + "menu_item, expected_endpoint, expected_input_subset", |
| 333 | + [ |
| 334 | + (MenuItem.ASSETS, "is_authorized_asset", {"method": "GET"}), |
| 335 | + ( |
| 336 | + MenuItem.AUDIT_LOG, |
| 337 | + "is_authorized_dag", |
| 338 | + {"method": "GET", "access_entity": "AUDIT_LOG"}, |
| 339 | + ), |
| 340 | + (MenuItem.CONFIG, "is_authorized_configuration", {"method": "GET"}), |
| 341 | + (MenuItem.CONNECTIONS, "is_authorized_connection", {"method": "GET"}), |
| 342 | + ( |
| 343 | + MenuItem.DAGS, |
| 344 | + "is_authorized_dag", |
| 345 | + {"method": "GET", "access_entity": None}, |
| 346 | + ), |
| 347 | + (MenuItem.DOCS, "is_authorized_view", {"access_view": "DOCS"}), |
| 348 | + (MenuItem.PLUGINS, "is_authorized_view", {"access_view": "PLUGINS"}), |
| 349 | + (MenuItem.POOLS, "is_authorized_pool", {"method": "GET"}), |
| 350 | + (MenuItem.PROVIDERS, "is_authorized_view", {"access_view": "PROVIDERS"}), |
| 351 | + (MenuItem.VARIABLES, "is_authorized_variable", {"method": "GET"}), |
| 352 | + ( |
| 353 | + MenuItem.XCOMS, |
| 354 | + "is_authorized_dag", |
| 355 | + {"method": "GET", "access_entity": "XCOM"}, |
| 356 | + ), |
| 357 | + ], |
| 358 | + ) |
| 359 | + def test_filter_authorized_menu_items_routes_through_opa( |
| 360 | + self, |
| 361 | + menu_item, |
| 362 | + expected_endpoint, |
| 363 | + expected_input_subset, |
| 364 | + auth_manager_with_appbuilder, |
| 365 | + mock_opa, |
| 366 | + ): |
| 367 | + # Each MenuItem must trigger a request to the matching OPA endpoint |
| 368 | + # with the expected input, so menu visibility is OPA-driven rather |
| 369 | + # than FAB-DB-driven, and the Rego wire contract is documented. |
| 370 | + user = _make_user() |
| 371 | + |
| 372 | + mock_opa.decide = lambda endpoint, body: True |
| 373 | + allowed = auth_manager_with_appbuilder.filter_authorized_menu_items( |
| 374 | + [menu_item], user=user |
| 375 | + ) |
| 376 | + assert allowed == [menu_item] |
| 377 | + |
| 378 | + assert len(mock_opa.calls) == 1 |
| 379 | + endpoint, body = mock_opa.calls[0] |
| 380 | + assert endpoint == expected_endpoint |
| 381 | + assert expected_input_subset.items() <= body["input"].items() |
| 382 | + |
| 383 | + # Deny path: a fresh OPA decision actually filters the item out, |
| 384 | + # proving the dispatch consults OPA rather than always allowing. |
| 385 | + auth_manager_with_appbuilder.opa_cache.clear() |
| 386 | + mock_opa.decide = lambda endpoint, body: False |
| 387 | + denied = auth_manager_with_appbuilder.filter_authorized_menu_items( |
| 388 | + [menu_item], user=user |
| 389 | + ) |
| 390 | + assert denied == [] |
| 391 | + |
| 392 | + def test_filter_authorized_menu_items_denies_unknown( |
| 393 | + self, auth_manager_with_appbuilder, mock_opa |
| 394 | + ): |
| 395 | + # A MenuItem value not handled by _is_menu_item_authorized (e.g. one |
| 396 | + # introduced in a future Airflow version) must fail closed: denied |
| 397 | + # without consulting OPA, so a new UI surface isn't silently exposed |
| 398 | + # before the dispatch table is updated. |
| 399 | + unknown = Mock(spec=MenuItem) |
| 400 | + unknown.name = "FUTURE_THING" |
| 401 | + |
| 402 | + result = auth_manager_with_appbuilder.filter_authorized_menu_items( |
| 403 | + [unknown], user=_make_user() |
| 404 | + ) |
| 405 | + |
| 406 | + assert result == [] |
| 407 | + assert mock_opa.calls == [] |
| 408 | + |
| 409 | + def test_filter_authorized_menu_items_preserves_order_and_filters( |
| 410 | + self, auth_manager_with_appbuilder, mock_opa |
| 411 | + ): |
| 412 | + user = _make_user() |
| 413 | + |
| 414 | + def decide(endpoint, body): |
| 415 | + if endpoint == "is_authorized_dag": |
| 416 | + # Allow DAGs root menu, deny the XCOM access entity. |
| 417 | + return body["input"].get("access_entity") is None |
| 418 | + if endpoint == "is_authorized_connection": |
| 419 | + return True |
| 420 | + return False |
| 421 | + |
| 422 | + mock_opa.decide = decide |
| 423 | + |
| 424 | + result = auth_manager_with_appbuilder.filter_authorized_menu_items( |
| 425 | + [MenuItem.DAGS, MenuItem.DOCS, MenuItem.CONNECTIONS, MenuItem.XCOMS], |
| 426 | + user=user, |
| 427 | + ) |
| 428 | + |
| 429 | + assert result == [MenuItem.DAGS, MenuItem.CONNECTIONS] |
0 commit comments