Skip to content

Conversation

@tanujdargan
Copy link
Contributor

Fixes: #51299 - Impossible to load UI if user does not have read permissions on plugins

Summary

This PR ensures the Airflow UI loads gracefully for authenticated users who do not have read permissions on plugins.

Details

  • Updated approach to work following Adjustment external views #51889 merge.
  • Refactored routes/ui/config.py to safely handle cases where plugins_manager.flask_appbuilder_menu_links is None, preventing 403 errors from blocking the UI.
  • Adjusted import order for clarity and maintainability.
  • The UI now loads extra plugin menu items from a new plugins_extra_menu_items attribute in the UI config endpoint, so all authenticated users can see them, regardless of direct plugin permissions.
  • The UI no longer calls the public/providers endpoint for these menu items.
  • Fixes failing CI pipeline and other tests.

Context

Previously, users without read permissions on plugins would receive a 403 Forbidden error, causing the entire UI to fail rendering. With this fix, the UI continues to render and display default elements, and plugin menu items are provided in a way accessible to all authenticated users.


Closes #51299.

Copy link
Member

@jason810496 jason810496 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Thanks for raising the PR.

dependencies=[Depends(requires_authenticated())],
)
def get_configs() -> ConfigResponse:
async def get_configs(user: GetUserDep) -> ConfigResponse:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to change this router to async ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_config depends on GetUserDep which is async and as per FastAPI rule: any route function that calls an async dependency must also be async.

and, the previous implementation allowed any authenticated user to see all UI plugins, which was a security issue. My PR fixes this by correctly checking the user's permissions for each plugin before displaying it. While the default Viewer role in the documentation is shown to have plugin access, my change ensures the system properly enforces whatever permissions are actually configured. So, if a Viewer has no plugin permissions, they will no longer see any plugin menu items."

Copy link
Member

@pierrejeambrun pierrejeambrun Jul 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_config depends on GetUserDep which is async and as per FastAPI rule: any route function that calls an async dependency must also be async.

Where do you get this information from ? I tend to think that it does not matter and this is confirmed by FastAPI documentation:
https://fastapi.tiangolo.com/tutorial/dependencies/#to-async-or-not-to-async

Comment on lines 58 to 63
auth_manager = get_auth_manager()
authorized_plugins = []
if plugins_manager.external_views:
for view in plugins_manager.external_views:
if auth_manager.is_authorized_custom_view(method="GET", resource_name=view["name"], user=user):
authorized_plugins.append(view)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to doc the #51714 (review) comment here to describe the logic ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup f41d1c9

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we are mixing things up. Why would plugin permissions be in the auth manger custom views permissions? Those are not related, permissions to access plugins is AccessView.PLUGINS.

Also we assume that this subpart of the plugins (external views in the nav) are public, so everybody can load the UI or access it.

from tests_common.test_utils.config import conf_vars

pytestmark = pytest.mark.db_test
pytestmark = [pytest.mark.db_test, pytest.mark.mock_plugin_manager(plugins=[])]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about we don't mock the plugin manager return an empty list. Let's make the external_views return some views that belongs to nav and some are not belongs to nav to ensure the implementaion in router.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, just pushed a fix for this! f41d1c9

Comment on lines 39 to 41
// Only show external plugins in menu if there are more than 2
const menuExternalViews = menuPlugins.length > 2 ? menuPlugins : [];
const directExternalViews = menuPlugins.length <= 2 ? menuPlugins : [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we lose the code to consolidate plugin views into a menu when there are many of them?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current logic does seem to replace the more granular is_menu_item flag-based separation with a hard cutoff at 2 items:

This effectively overrides the earlier logic that distinguishes between menuExternalViews and directExternalViews using the is_menu_item property. If we previously consolidated plugins into a dropdown based on that flag (rather than count), this could be a regression or unintentional change.

Should I revert to using is_menu_item to preserve expected grouping behaviour?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this PR shouldn't change how we actually show plugins in the nav bar.

Copy link
Member

@pierrejeambrun pierrejeambrun left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few suggestions / comments.

config = {key: conf.get("api", key) for key in API_CONFIG_KEYS}

task_log_reader = TaskLogReader()
plugins_manager.initialize_ui_plugins()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You do not need to do that, this is done at the init of the FastAPI app, can you remove it?

dependencies=[Depends(requires_authenticated())],
)
def get_configs() -> ConfigResponse:
async def get_configs(user: GetUserDep) -> ConfigResponse:
Copy link
Member

@pierrejeambrun pierrejeambrun Jul 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_config depends on GetUserDep which is async and as per FastAPI rule: any route function that calls an async dependency must also be async.

Where do you get this information from ? I tend to think that it does not matter and this is confirmed by FastAPI documentation:
https://fastapi.tiangolo.com/tutorial/dependencies/#to-async-or-not-to-async

Comment on lines 58 to 63
auth_manager = get_auth_manager()
authorized_plugins = []
if plugins_manager.external_views:
for view in plugins_manager.external_views:
if auth_manager.is_authorized_custom_view(method="GET", resource_name=view["name"], user=user):
authorized_plugins.append(view)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we are mixing things up. Why would plugin permissions be in the auth manger custom views permissions? Those are not related, permissions to access plugins is AccessView.PLUGINS.

Also we assume that this subpart of the plugins (external views in the nav) are public, so everybody can load the UI or access it.


# First, we check which of the available external plugin views the current user is authorized to see.
auth_manager = get_auth_manager()
authorized_plugins = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should rename this, those are external_views, not plugins.

Comment on lines 33 to 34
const menuPlugins = (useConfig("plugins_extra_menu_items") as Array<ExternalViewResponse>) ?? [];

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't need a hard casting like that. "as"

Comment on lines 35 to 36
const directExternalViews = menuPlugins.filter((p: ExternalViewResponse) => !p.is_menu_item);
const menuExternalViews = menuPlugins.filter((p: ExternalViewResponse) => p.is_menu_item);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what's going on here. What is is_menu_item attribute, that does not exist.


class TestGetConfig:
@pytest.mark.mock_plugin_manager(plugins=[])
class TestGetConfigWithNoPlugins:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 class per endpoint. Multiple method for different test cases. We shouldn't have two classes there.

@pierrejeambrun
Copy link
Member

Also, just an idea, but maybe we can handle that simply in the UI. Call the endoint if 403 forbidden -> instead of crashing everything, catch the error and return an empty plugin list, then the UI will proceed ignoring the plugins.

Because mixing up the plugin and the config do not feel super clean

Copy link
Contributor

@bbovenzi bbovenzi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to Pierre's comment to keep the plugins endpoint and just handle a 403 better.

Marking as changes requested because there is a lot of hallucinated AI code in this PR that needs to be corrected. Please review your AI generated code before opening a PR. Otherwise it forces code reviewers to do your work for you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:API Airflow's REST/HTTP API area:UI Related to UI/UX. For Frontend Developers.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug - Impossible to load UI if user does not have read permissions on plugins

4 participants