You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by el...@apache.org on 2026/09/13 15:13:59 UTC
(superset) 01/01: fix(api): return 401 not 500 for auth errors in CurrentUserRestApi (SC-120417)
This is an automated email from the ASF dual-hosted git repository.
eschutho pushed a commit to branch fix-currentuser-userlookuperror-500
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 5062bb7949f501df2b79d4a1a726b4ed7b024635
Author: Elizabeth Thompson <es...@gmail.com>
AuthorDate: Sun Sep 13 15:13:41 2026 +0000
fix(api): return 401 not 500 for auth errors in CurrentUserRestApi (SC-120417)
The three /api/v1/me/ endpoints (get_me, get_my_roles, update_me) resolve
g.user (a flask-login lazy proxy) inside their bodies. For a validly
authenticated JWT whose bearer has no assigned role, that resolution raises
UserLookupError; a missing/invalid authorization context raises
NoAuthorizationError. Neither was caught: the exception propagated to
flask_appbuilder's @safe decorator, which logs a full ERROR traceback and
returns a generic 500 instead of a clean 401.
PR #36410 removed the manual try/except guards these methods used to carry,
assuming @protect() covers all auth failures -- it does not, and it never
covered UserLookupError at all. Mirror the established views/base.py::api
pattern: catch the auth-outcome exceptions, log at WARNING, and return 401.
Fixes SUPERSET-PYTHON-NMA
---
superset/views/users/api.py | 21 +++-
.../views/test_current_user_auth_errors.py | 122 +++++++++++++++++++++
2 files changed, 140 insertions(+), 3 deletions(-)
diff --git a/superset/views/users/api.py b/superset/views/users/api.py
index ce2d53892be..cf3ed95a015 100644
--- a/superset/views/users/api.py
+++ b/superset/views/users/api.py
@@ -14,6 +14,7 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+import logging
from datetime import datetime
from typing import Any, Dict
@@ -21,6 +22,7 @@ from flask import current_app as app, g, redirect, request, Response
from flask_appbuilder.api import expose, permission_name, safe
from flask_appbuilder.security.decorators import protect
from flask_appbuilder.security.sqla.models import User
+from flask_jwt_extended.exceptions import NoAuthorizationError, UserLookupError
from marshmallow import ValidationError
from sqlalchemy.orm.exc import NoResultFound
from werkzeug.security import check_password_hash, generate_password_hash
@@ -34,6 +36,8 @@ from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metri
from superset.views.users.schemas import CurrentUserPutSchema, UserResponseSchema
from superset.views.utils import bootstrap_user_data
+logger = logging.getLogger(__name__)
+
user_response_schema = UserResponseSchema()
@@ -115,7 +119,11 @@ class CurrentUserRestApi(BaseSupersetApi):
401:
$ref: '#/components/responses/401'
"""
- return self.response(200, result=user_response_schema.dump(g.user))
+ try:
+ return self.response(200, result=user_response_schema.dump(g.user))
+ except (NoAuthorizationError, UserLookupError):
+ logger.warning("Api failed- no authorization", exc_info=True)
+ return self.response_401()
@expose("/roles/", methods=("GET",))
@protect()
@@ -142,8 +150,12 @@ class CurrentUserRestApi(BaseSupersetApi):
401:
$ref: '#/components/responses/401'
"""
- user = bootstrap_user_data(g.user, include_perms=True)
- return self.response(200, result=user)
+ try:
+ user = bootstrap_user_data(g.user, include_perms=True)
+ return self.response(200, result=user)
+ except (NoAuthorizationError, UserLookupError):
+ logger.warning("Api failed- no authorization", exc_info=True)
+ return self.response_401()
@expose("/", methods=["PUT"])
@protect()
@@ -194,6 +206,9 @@ class CurrentUserRestApi(BaseSupersetApi):
return self.response(200, result=user_response_schema.dump(g.user))
except ValidationError as error:
return self.response_400(message=error.messages)
+ except (NoAuthorizationError, UserLookupError):
+ logger.warning("Api failed- no authorization", exc_info=True)
+ return self.response_401()
class UserRestApi(BaseSupersetApi):
diff --git a/tests/unit_tests/views/test_current_user_auth_errors.py b/tests/unit_tests/views/test_current_user_auth_errors.py
new file mode 100644
index 00000000000..2d258fd87ef
--- /dev/null
+++ b/tests/unit_tests/views/test_current_user_auth_errors.py
@@ -0,0 +1,122 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Unit tests for auth-outcome error handling in ``CurrentUserRestApi``.
+
+The three ``/api/v1/me/`` endpoints resolve ``g.user`` (a flask-login lazy
+proxy) inside their bodies. For a validly-authenticated JWT whose bearer has
+no assigned role, that resolution raises ``UserLookupError``; a missing/invalid
+authorization context raises ``NoAuthorizationError``. Neither is an
+"unexpected" server failure -- both are auth outcomes and must surface as a
+clean ``401``, not fall through to ``@safe``'s generic ``500`` handler.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable
+
+import pytest
+from flask_jwt_extended.exceptions import NoAuthorizationError, UserLookupError
+from pytest_mock import MockerFixture
+
+
+class _RaisingG:
+ """Stand-in for the request-global whose ``user`` attribute raises.
+
+ Mirrors flask-login's lazy proxy: nothing fails until ``g.user`` is
+ actually dereferenced inside the endpoint body.
+ """
+
+ def __init__(self, exc: Exception) -> None:
+ self._exc = exc
+
+ @property
+ def user(self) -> Any:
+ raise self._exc
+
+
+# ``UserLookupError`` requires ``(message, jwt_header, jwt_data)``; the values
+# of the latter two are irrelevant to the handler under test, which keys only
+# off the exception type.
+_USER_LOOKUP_ERROR = UserLookupError("No access", {}, {})
+_NO_AUTH_ERROR = NoAuthorizationError("Missing Authorization Header")
+
+
+@pytest.fixture
+def raise_on_g_user(
+ mocker: MockerFixture,
+) -> Callable[[Exception], None]:
+ """Patch the module-level ``g`` in the users API so ``g.user`` raises.
+
+ Returns a setter the test calls with the exception it wants raised, so a
+ single fixture covers both the ``UserLookupError`` and
+ ``NoAuthorizationError`` cases.
+ """
+
+ def _set(exc: Exception) -> None:
+ mocker.patch("superset.views.users.api.g", _RaisingG(exc))
+
+ return _set
+
+
+@pytest.mark.parametrize(
+ "exc",
+ [_USER_LOOKUP_ERROR, _NO_AUTH_ERROR],
+ ids=["user_lookup_error", "no_authorization_error"],
+)
+@pytest.mark.parametrize(
+ "path",
+ ["/api/v1/me/", "/api/v1/me/roles/"],
+ ids=["get_me", "get_my_roles"],
+)
+def test_get_endpoints_return_401_not_500_on_auth_error(
+ client: Any,
+ full_api_access: None,
+ raise_on_g_user: Any,
+ path: str,
+ exc: Exception,
+) -> None:
+ """``GET /api/v1/me/`` and ``/api/v1/me/roles/`` translate an auth-outcome
+ exception raised while resolving ``g.user`` into a ``401`` -- not the
+ generic ``500`` that ``@safe`` would produce for an uncaught exception.
+ """
+ raise_on_g_user(exc)
+
+ response = client.get(path)
+
+ assert response.status_code == 401
+
+
+@pytest.mark.parametrize(
+ "exc",
+ [_USER_LOOKUP_ERROR, _NO_AUTH_ERROR],
+ ids=["user_lookup_error", "no_authorization_error"],
+)
+def test_update_me_returns_401_not_500_on_auth_error(
+ client: Any,
+ full_api_access: None,
+ raise_on_g_user: Any,
+ exc: Exception,
+) -> None:
+ """``PUT /api/v1/me/`` translates an auth-outcome exception raised while
+ resolving ``g.user`` into a ``401``. The existing ``ValidationError``
+ branch is unaffected: the new except clause lives alongside it.
+ """
+ raise_on_g_user(exc)
+
+ response = client.put("/api/v1/me/", json={"first_name": "Foo"})
+
+ assert response.status_code == 401