Honor AUTH_ROLE_PUBLIC in FastAPI API server#65685
Honor AUTH_ROLE_PUBLIC in FastAPI API server#65685gaurav0107 wants to merge 3 commits intoapache:mainfrom
Conversation
The FastAPI API server's bearer-token auth raised 401 for anonymous users even when AUTH_ROLE_PUBLIC was set, because the gate only honored Flask's webserver_config.py and the FastAPI stack had no equivalent. This adds a new [fab] auth_role_public Airflow config, surfaced via a new BaseAuthManager.get_public_user() hook. FabAuthManager populates an AnonymousUser with the public role's permissions pre-expanded so it works outside a Flask request context. The legacy AUTH_ROLE_PUBLIC setting in webserver_config.py keeps working via a startup-time bridge that copies [fab] auth_role_public into Flask's config. Closes apache#60897 Co-Authored-By: Claude <noreply@anthropic.com>
|
Congratulations on your first Pull Request and welcome to the Apache Airflow community! If you have any issues or are unsure about any anything please check our Contributors' Guide
|
| log.error("Couldn't deserialize user from token, JWT token is not valid: %s", e) | ||
| raise InvalidTokenError(str(e)) | ||
|
|
||
| def get_public_user(self) -> BaseUser | None: |
There was a problem hiding this comment.
I am not a big fan of this new method, with simple auth manager we went with another approach. We went wth creating a middleware which automatically create a token. Please look at airflow-core/src/airflow/api_fastapi/auth/managers/simple/middleware.py
There was a problem hiding this comment.
Hey @vincbeck, thanks so much for the pointer! Really appreciate you taking the time to flag this — the middleware pattern is much cleaner and I should have looked at SimpleAllAdminMiddleware first.
I've just pushed a refactor (6041356) that follows the same approach:
- New
FabAuthRolePublicMiddlewareinproviders/fab/auth_manager/— mirrors the structure ofSimpleAllAdminMiddleware - It attaches an
AnonymousUsertorequest.state.useron unauthenticated requests (the existingget_userdependency already short-circuits onrequest.state.user, so I skipped the JWT-minting step that simple does) BaseAuthManagernow has a small genericget_fastapi_middlewares()hook so any auth manager can plug in middleware without touching airflow-coresecurity.pyis back to the original upstream 401 behavior — no opinions baked into the core auth gate anymore
The middleware only registers when [fab] auth_role_public is configured, same gating as before.
Let me know if you'd like me to adjust anything — happy to iterate. 🙏
Per @vincbeck's review, replace the BaseAuthManager.get_public_user() method with a middleware approach mirroring SimpleAllAdminMiddleware. - Add generic BaseAuthManager.get_fastapi_middlewares() extension hook that auth managers override to register their own middlewares. - New FabAuthRolePublicMiddleware attaches an AnonymousUser to request.state.user for unauthenticated requests when [fab] auth_role_public is configured. The FastAPI get_user dependency short-circuits on request.state.user, so no JWT minting is needed. - Revert security.py to upstream behavior (401 on missing token). - Rename FabAuthManager.get_public_user -> build_public_user and mark as :meta private: since it's an implementation detail consumed only by the middleware. Addresses apache#65685 (comment) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| in ``$AIRFLOW_HOME/webserver_config.py`` needs to be set with the desired role that the Anonymous | ||
| user will have by default: | ||
| To deactivate the authentication and allow users to be identified as Anonymous, set the | ||
| ``[fab] auth_role_public`` Airflow configuration to the desired role that anonymous users will have |
There was a problem hiding this comment.
That is fab specific, it should not be in airflow core documentation
There was a problem hiding this comment.
Good call — reverted the doc expansion entirely. The AUTH_ROLE_PUBLIC paragraph is back to the upstream wording, and the new [fab] auth_role_public config is documented via provider.yaml (which shows up in the generated config reference). Let me know if you'd rather I add a short sentence somewhere pointing users at the new key.
| @@ -0,0 +1 @@ | |||
| Honor ``AUTH_ROLE_PUBLIC`` in the FastAPI-based API server. Add a new ``[fab] auth_role_public`` Airflow configuration that is used consistently by both the FastAPI and legacy Flask FAB auth stacks so that setting a public role actually removes the login prompt. Setting ``AUTH_ROLE_PUBLIC`` in ``webserver_config.py`` keeps working for backwards compatibility. | |||
| return None | ||
|
|
||
| user = AnonymousUser() | ||
| flask_app = self.flask_app or (self.appbuilder.app if self.appbuilder else None) |
There was a problem hiding this comment.
self.appbuilder.app should be the only way to access the app. self.flask_app should be removed
There was a problem hiding this comment.
Done — build_public_user no longer touches self.flask_app. It resolves the role via a SQLAlchemy session and sets _roles/_perms directly on the AnonymousUser, so no Flask context is opened in the auth manager.
| with flask_app.app_context(): | ||
| flask_app.config["AUTH_ROLE_PUBLIC"] = public_role_name | ||
| role = flask_app.appbuilder.sm.find_role(public_role_name) | ||
| if role is not None: | ||
| # ``AnonymousUser.roles`` is a lazy property that calls | ||
| # ``security_manager.get_public_role()`` on every access, which needs a Flask | ||
| # request context we do not have under FastAPI. Writing ``_roles``/``_perms`` | ||
| # directly freezes a snapshot of the public role's permissions for the | ||
| # lifetime of a single FastAPI authorization check (see #60897). | ||
| user._roles = {role} | ||
| user._perms = {(perm.action.name, perm.resource.name) for perm in role.permissions} |
There was a problem hiding this comment.
Instead of doing this, I think it would be simpler to create a User object by and (like simple auth manager). Ideally I do not want to create a Flask context in this file
There was a problem hiding this comment.
Switched to the same shape as simple auth manager — no Flask context, no app_context(), no find_role hop through the security manager. The new version queries the Role via @provide_session (with permissions joined eager-loaded on the model) and snapshots _roles/_perms on the AnonymousUser before the session closes, so downstream _is_authorized checks only need user.perms (already materialized as tuples) and don't hit Flask at all.
One small caveat: unlike the simple auth manager, FAB's User is a SQLAlchemy model and its real login flow expects DB-backed roles, so I'm still using the existing AnonymousUser class rather than constructing a User(...) by hand — the pre-populated _roles/_perms path is exactly what AnonymousUser was designed for. Happy to go further if you'd prefer a purpose-built anonymous model though.
| auth_role_public_conf = conf.get("fab", "auth_role_public", fallback="") or "" | ||
| if auth_role_public_conf: | ||
| flask_app.config["AUTH_ROLE_PUBLIC"] = auth_role_public_conf |
There was a problem hiding this comment.
Nice, since we are doing that, there is no need to check for conf.get("fab", "auth_role_public") in other places?
There was a problem hiding this comment.
Yes — cleaned up. The only other caller was _get_auth_role_public in the FAB auth manager, and it now reads appbuilder.app.config["AUTH_ROLE_PUBLIC"] directly. The www/app.py bridge is the single place that reads the Airflow config, and everything else downstream reads the Flask config. There were no other conf.get("fab", "auth_role_public") call sites.
| @@ -174,6 +175,9 @@ def init_middlewares(app: FastAPI) -> None: | |||
|
|
|||
| app.add_middleware(SimpleAllAdminMiddleware) | |||
There was a problem hiding this comment.
This then should be removed and simple auth manager should override get_fastapi_middlewares
There was a problem hiding this comment.
Done — removed the hardcoded SimpleAllAdminMiddleware branch from init_middlewares and pushed the registration into SimpleAuthManager.get_fastapi_middlewares. Both FAB and simple now go through the same generic hook. Added unit tests for the new override too.
|
@gaurav0107 This PR has been converted to draft because it does not yet meet our Pull Request quality criteria. Issues found:
What to do next:
Converting a PR to draft is not a rejection — it is an invitation to bring the PR up to the project's standards so that maintainer review time is spent productively. There is no rush — take your time and work at your own pace. We appreciate your contribution and are happy to wait for updates. If you have questions, feel free to ask on the Airflow Slack. |
…iring Follow-up review from @vincbeck on PR apache#65685. Shrinks the diff and aligns the FAB public-access path with the simple auth manager's patterns. - ``FabAuthManager.build_public_user`` no longer opens a Flask app context or uses ``self.flask_app``. It resolves the public role via a SQLAlchemy session (``@provide_session``), eager-loaded joined permissions, and writes ``_roles``/``_perms`` directly on the ``AnonymousUser``. No Flask context is created in the auth manager. - ``FabAuthManager._get_auth_role_public`` reads only the Flask app config. The ``providers/fab/www/app.py`` bridge already copies ``[fab] auth_role_public`` into ``AUTH_ROLE_PUBLIC`` at app creation, so there's a single source of truth and no duplicate ``conf.get``. - ``SimpleAuthManager`` now overrides ``get_fastapi_middlewares`` to register ``SimpleAllAdminMiddleware`` when ``[core] simple_auth_manager_all_admins`` is set. The hardcoded registration in ``init_middlewares`` is removed so all auth managers go through the same generic hook. - Revert the ``webserver-authentication.rst`` expansion; the existing ``AUTH_ROLE_PUBLIC`` paragraph is enough and the new config key is documented via ``provider.yaml``. - Drop the 65685 newsfragment per maintainer feedback. Tests updated to match the new behavior (Flask config is the single source of truth read by the auth manager) and extended with two new tests for ``SimpleAuthManager.get_fastapi_middlewares``. Co-Authored-By: Claude <noreply@anthropic.com>
|
Quick follow-up to the triage comment above — one clarification on the "Unresolved review comments" item: Once you believe a thread has been addressed — whether by pushing a fix, or by replying in-thread with an explanation of why the suggestion doesn't apply — please mark the thread as resolved yourself by clicking the "Resolve conversation" button at the bottom of each thread. Reviewers don't auto-close their own threads, so an addressed-but-unresolved thread reads as "still waiting on the author" and keeps the PR from moving forward. The author doing the resolve-click is the expected convention on this project. |
Summary
Closes #60897.
The FastAPI API server's auth gate rejected anonymous users with 401 even when
AUTH_ROLE_PUBLICwas set inwebserver_config.py, because the gate only consulted Flask's config. The Flask legacy webserver honored it; FastAPI did not.Approach
Per @jason810496's proposal (endorsed by @vincbeck in the issue thread):
[fab] auth_role_publicAirflow config — the canonical source going forward.BaseAuthManager.get_public_user()hook (returnsNoneby default).FabAuthManager.get_public_user()resolves the configured role, builds anAnonymousUserwith_roles/_permspre-populated insideapp_context(), and returns it.resolve_user_from_token()in the FastAPI security layer consultsauth_manager.get_public_user()before raising 401.providers/fab/www/app.pycopies[fab] auth_role_publicinto Flask'sAUTH_ROLE_PUBLICso the legacy webserver's existing code paths stay in sync — no behavior change for users onwebserver_config.pyonly.Why the
AnonymousUser._rolesdirect-write?FAB's
AnonymousUser.rolesis a lazy property that callssecurity_manager.get_public_role()on every access, which requires a Flask request context.get_public_user()runs under FastAPI where there is no request context, so we build the user inside a briefapp_context(), resolve permissions eagerly, and write them directly to_roles/_permsto freeze the snapshot. This is the minimum surface needed to avoid touching FAB's property implementation while keeping the call safe outside Flask.Type of change
Testing
Backwards compatibility
Users relying on `AUTH_ROLE_PUBLIC = "Admin"` in `webserver_config.py` continue to work unchanged — the Flask webserver keeps consulting that value, and the FastAPI gate now also honors it via the startup bridge.
cc @vincbeck @jason810496
🤖 Generated with Claude Code