aminghadersohi commented on code in PR #39604:
URL: https://github.com/apache/superset/pull/39604#discussion_r3290464377


##########
superset/security/manager.py:
##########
@@ -1361,6 +1365,15 @@ def create_custom_permissions(self) -> None:
         self.add_permission_view_menu("can_tag", "Chart")
         self.add_permission_view_menu("can_tag", "Dashboard")
 
+        # API Key permissions (FAB's ApiKeyApi blueprint).
+        # Superset uses AppBuilder(update_perms=False) so FAB skips
+        # permission creation during blueprint registration. Create them
+        # explicitly here so that ``superset init`` picks them up and
+        # sync_role_definitions assigns them to the Admin role.
+        if current_app.config.get("FAB_API_KEY_ENABLED", False):
+            for perm in ("can_list", "can_create", "can_get", "can_delete"):
+                self.add_permission_view_menu(perm, "ApiKey")

Review Comment:
   The approach changed in a subsequent commit (fix(security): drop redundant 
explicit ApiKey perm creation) — `create_custom_permissions()` was not 
modified. Only `ADMIN_ONLY_VIEW_MENUS` was updated (adding `"ApiKey"` to the 
static set). No new behavior was added to `create_custom_permissions()`, so no 
new test is needed for that function. The existing `ADMIN_ONLY_VIEW_MENUS` 
coverage in the RBAC tests exercises the gating logic.



##########
superset/mcp_service/composite_token_verifier.py:
##########
@@ -0,0 +1,104 @@
+# 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.
+
+"""
+Composite token verifier for MCP authentication.
+
+Routes Bearer tokens to the appropriate verifier based on prefix:
+- Tokens matching FAB_API_KEY_PREFIXES (e.g. ``sst_``) are passed through
+  to the Flask layer where ``_resolve_user_from_api_key()`` handles
+  actual validation via FAB SecurityManager.
+- All other tokens are delegated to the wrapped JWT verifier (when one is
+  configured); when no JWT verifier is configured, non-API-key tokens are
+  rejected at the transport layer.
+"""
+
+import logging
+
+from fastmcp.server.auth import AccessToken
+from fastmcp.server.auth.providers.jwt import TokenVerifier
+
+logger = logging.getLogger(__name__)
+
+# Namespaced claim that flags an AccessToken as an API-key pass-through.
+# Namespacing avoids collision with custom claims an external IdP might
+# happen to mint on a JWT — a plain ``_api_key_passthrough`` claim could
+# be silently misidentified as a Superset API-key request.
+API_KEY_PASSTHROUGH_CLAIM = "_superset_mcp_api_key_passthrough"
+
+
+class CompositeTokenVerifier(TokenVerifier):
+    """Routes Bearer tokens between API key pass-through and JWT verification.
+
+    API key tokens (identified by prefix) are accepted at the transport layer
+    with a marker claim so that ``_resolve_user_from_jwt_context()`` can
+    detect them and fall through to ``_resolve_user_from_api_key()`` for
+    actual validation.
+
+    Args:
+        jwt_verifier: The wrapped JWT verifier for non-API-key tokens.
+            When ``None``, only API-key tokens are accepted; all other
+            Bearer tokens are rejected at the transport layer (used when
+            ``MCP_AUTH_ENABLED=False`` but ``FAB_API_KEY_ENABLED=True``).
+        api_key_prefixes: List of prefixes that identify API key tokens
+            (e.g. ``["sst_"]``).
+    """
+
+    def __init__(
+        self,
+        jwt_verifier: TokenVerifier | None,
+        api_key_prefixes: list[str],
+    ) -> None:
+        super().__init__(
+            base_url=getattr(jwt_verifier, "base_url", None),
+            required_scopes=getattr(jwt_verifier, "required_scopes", None) or 
[],
+        )
+        self._jwt_verifier = jwt_verifier
+        self._api_key_prefixes = tuple(api_key_prefixes)
+
+    async def verify_token(self, token: str) -> AccessToken | None:
+        """Verify a Bearer token.
+
+        If the token starts with an API key prefix, return a pass-through
+        AccessToken with a ``_api_key_passthrough`` claim. The Flask-layer

Review Comment:
   The docstring was updated in a prior commit — it now reads 
`_superset_mcp_api_key_passthrough` (the namespaced constant 
`API_KEY_PASSTHROUGH_CLAIM`). See composite_token_verifier.py line 90.



##########
superset/mcp_service/mcp_config.py:
##########
@@ -284,56 +287,88 @@
 
 
 def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]:
-    """Default MCP auth factory using app.config values."""
-    if not app.config.get("MCP_AUTH_ENABLED", False):
-        return None
+    """Default MCP auth factory using app.config values.
 
-    jwks_uri = app.config.get("MCP_JWKS_URI")
-    public_key = app.config.get("MCP_JWT_PUBLIC_KEY")
-    secret = app.config.get("MCP_JWT_SECRET")
+    Returns an auth provider when ``MCP_AUTH_ENABLED=True`` (JWT verifier,
+    optionally wrapped with ``CompositeTokenVerifier`` for API keys) or
+    when only ``FAB_API_KEY_ENABLED=True`` (API-key-only verifier that
+    rejects all non-API-key Bearer tokens at the transport).
+    """
+    auth_enabled = app.config.get("MCP_AUTH_ENABLED", False)
+    api_key_enabled = app.config.get("FAB_API_KEY_ENABLED", False)
 
-    if not (jwks_uri or public_key or secret):
-        logger.warning("MCP_AUTH_ENABLED is True but no JWT keys/secret 
configured")
+    if not (auth_enabled or api_key_enabled):
         return None
 
-    try:
-        debug_errors = app.config.get("MCP_JWT_DEBUG_ERRORS", False)
+    jwt_verifier: Any | None = None
 
-        common_kwargs: dict[str, Any] = {
-            "issuer": app.config.get("MCP_JWT_ISSUER"),
-            "audience": app.config.get("MCP_JWT_AUDIENCE"),
-            "required_scopes": app.config.get("MCP_REQUIRED_SCOPES", []),
-        }
+    if auth_enabled:
+        jwks_uri = app.config.get("MCP_JWKS_URI")
+        public_key = app.config.get("MCP_JWT_PUBLIC_KEY")
+        secret = app.config.get("MCP_JWT_SECRET")
 
-        # For HS256 (symmetric), use the secret as the public_key parameter
-        if app.config.get("MCP_JWT_ALGORITHM") == "HS256" and secret:
-            common_kwargs["public_key"] = secret
-            common_kwargs["algorithm"] = "HS256"
-        else:
-            # For RS256 (asymmetric), use public key or JWKS
-            common_kwargs["jwks_uri"] = jwks_uri
-            common_kwargs["public_key"] = public_key
-            common_kwargs["algorithm"] = app.config.get("MCP_JWT_ALGORITHM", 
"RS256")
-
-        if debug_errors:
-            # DetailedJWTVerifier: detailed server-side logging of JWT
-            # validation failures. HTTP responses are always generic per
-            # RFC 6750 Section 3.1.
-            from superset.mcp_service.jwt_verifier import DetailedJWTVerifier
-
-            auth_provider = DetailedJWTVerifier(**common_kwargs)
+        if not (jwks_uri or public_key or secret):
+            logger.warning("MCP_AUTH_ENABLED is True but no JWT keys/secret 
configured")
+            if not api_key_enabled:
+                return None
         else:
-            # Default JWTVerifier: minimal logging, generic error responses.
-            from fastmcp.server.auth.providers.jwt import JWTVerifier
+            try:
+                jwt_verifier = _build_jwt_verifier(
+                    app=app,
+                    jwks_uri=jwks_uri,
+                    public_key=public_key,
+                    secret=secret,
+                )
+            except Exception:

Review Comment:
   The bare `Exception` was replaced with specific types in an earlier commit: 
`except (ValueError, JoseError)` for the JWT verifier construction block, and 
`except TypeError` for the prefix normalization block.



##########
superset/mcp_service/auth.py:
##########
@@ -218,6 +220,25 @@
     if access_token is None:
         return None
 
+    # API key pass-through: CompositeTokenVerifier accepted this token
+    # at the transport layer but defers actual validation to
+    # _resolve_user_from_api_key() (priority 2 in get_user_from_request).
+    # Require client_id=="api_key" (set by CompositeTokenVerifier) in addition
+    # to the claim so that an external IdP JWT that happens to include the
+    # claim name is not misclassified as an API-key pass-through.
+    claims = getattr(access_token, "claims", None)
+    if isinstance(claims, dict) and claims.get(API_KEY_PASSTHROUGH_CLAIM):
+        if getattr(access_token, "client_id", None) == "api_key":
+            logger.debug(
+                "API key pass-through token detected, deferring to API key 
auth"
+            )
+            return None
+        logger.debug(
+            "Ignoring %s claim on non-API-key token (client_id=%r); processing 
as JWT",
+            API_KEY_PASSTHROUGH_CLAIM,

Review Comment:
   Addressed in the latest commit (fix(mcp): remove exc_info=True from 
tool-visibility debug log). The `exc_info=True` in the tool-visibility except 
block was the flagged path — it could include full tracebacks with sensitive 
local variables. Removed; the log message alone is sufficient.



##########
superset/mcp_service/mcp_config.py:
##########
@@ -284,56 +287,88 @@
 
 
 def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]:
-    """Default MCP auth factory using app.config values."""
-    if not app.config.get("MCP_AUTH_ENABLED", False):
-        return None
+    """Default MCP auth factory using app.config values.
 
-    jwks_uri = app.config.get("MCP_JWKS_URI")
-    public_key = app.config.get("MCP_JWT_PUBLIC_KEY")
-    secret = app.config.get("MCP_JWT_SECRET")
+    Returns an auth provider when ``MCP_AUTH_ENABLED=True`` (JWT verifier,
+    optionally wrapped with ``CompositeTokenVerifier`` for API keys) or
+    when only ``FAB_API_KEY_ENABLED=True`` (API-key-only verifier that
+    rejects all non-API-key Bearer tokens at the transport).
+    """
+    auth_enabled = app.config.get("MCP_AUTH_ENABLED", False)
+    api_key_enabled = app.config.get("FAB_API_KEY_ENABLED", False)
 
-    if not (jwks_uri or public_key or secret):
-        logger.warning("MCP_AUTH_ENABLED is True but no JWT keys/secret 
configured")
+    if not (auth_enabled or api_key_enabled):
         return None
 
-    try:
-        debug_errors = app.config.get("MCP_JWT_DEBUG_ERRORS", False)
+    jwt_verifier: Any | None = None
 
-        common_kwargs: dict[str, Any] = {
-            "issuer": app.config.get("MCP_JWT_ISSUER"),
-            "audience": app.config.get("MCP_JWT_AUDIENCE"),
-            "required_scopes": app.config.get("MCP_REQUIRED_SCOPES", []),
-        }
+    if auth_enabled:
+        jwks_uri = app.config.get("MCP_JWKS_URI")
+        public_key = app.config.get("MCP_JWT_PUBLIC_KEY")
+        secret = app.config.get("MCP_JWT_SECRET")
 
-        # For HS256 (symmetric), use the secret as the public_key parameter
-        if app.config.get("MCP_JWT_ALGORITHM") == "HS256" and secret:
-            common_kwargs["public_key"] = secret
-            common_kwargs["algorithm"] = "HS256"
-        else:
-            # For RS256 (asymmetric), use public key or JWKS
-            common_kwargs["jwks_uri"] = jwks_uri
-            common_kwargs["public_key"] = public_key
-            common_kwargs["algorithm"] = app.config.get("MCP_JWT_ALGORITHM", 
"RS256")
-
-        if debug_errors:
-            # DetailedJWTVerifier: detailed server-side logging of JWT
-            # validation failures. HTTP responses are always generic per
-            # RFC 6750 Section 3.1.
-            from superset.mcp_service.jwt_verifier import DetailedJWTVerifier
-
-            auth_provider = DetailedJWTVerifier(**common_kwargs)
+        if not (jwks_uri or public_key or secret):
+            logger.warning("MCP_AUTH_ENABLED is True but no JWT keys/secret 
configured")
+            if not api_key_enabled:
+                return None
         else:
-            # Default JWTVerifier: minimal logging, generic error responses.
-            from fastmcp.server.auth.providers.jwt import JWTVerifier
+            try:
+                jwt_verifier = _build_jwt_verifier(
+                    app=app,
+                    jwks_uri=jwks_uri,
+                    public_key=public_key,
+                    secret=secret,
+                )
+            except Exception:  # noqa: BLE001 — JWT lib raises many types; 
broad catch intentional
+                # Do not log the exception — it may contain secrets (e.g., key 
material)
+                logger.error("Failed to create MCP JWT verifier")
+                if not api_key_enabled:
+                    return None
+
+    if api_key_enabled:
+        api_key_prefixes = app.config.get("FAB_API_KEY_PREFIXES", ["sst_"])
+        logger.info("API key auth enabled for MCP")
+        return CompositeTokenVerifier(
+            jwt_verifier=jwt_verifier,
+            api_key_prefixes=api_key_prefixes,
+        )

Review Comment:
   Validation was added in a prior commit. `mcp_config.py` now normalizes 
`FAB_API_KEY_PREFIXES`: a plain string is wrapped in a list, non-iterable 
values fall back to the default with a warning (`except TypeError`), and 
empty/non-string entries are filtered out inside 
`CompositeTokenVerifier.__init__`.



##########
superset/mcp_service/composite_token_verifier.py:
##########
@@ -0,0 +1,105 @@
+# 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.
+
+"""
+Composite token verifier for MCP authentication.
+
+Routes Bearer tokens to the appropriate verifier based on prefix:
+- Tokens matching FAB_API_KEY_PREFIXES (e.g. ``sst_``) are passed through
+  to the Flask layer where ``_resolve_user_from_api_key()`` handles
+  actual validation via FAB SecurityManager.
+- All other tokens are delegated to the wrapped JWT verifier (when one is
+  configured); when no JWT verifier is configured, non-API-key tokens are
+  rejected at the transport layer.
+"""
+
+import logging
+
+from fastmcp.server.auth import AccessToken
+from fastmcp.server.auth.providers.jwt import TokenVerifier
+
+logger = logging.getLogger(__name__)
+
+# Namespaced claim that flags an AccessToken as an API-key pass-through.
+# Namespacing avoids collision with custom claims an external IdP might
+# happen to mint on a JWT — a plain ``_api_key_passthrough`` claim could
+# be silently misidentified as a Superset API-key request.
+API_KEY_PASSTHROUGH_CLAIM = "_superset_mcp_api_key_passthrough"
+
+
+class CompositeTokenVerifier(TokenVerifier):
+    """Routes Bearer tokens between API key pass-through and JWT verification.
+
+    API key tokens (identified by prefix) are accepted at the transport layer
+    with a marker claim so that ``_resolve_user_from_jwt_context()`` can
+    detect them and fall through to ``_resolve_user_from_api_key()`` for
+    actual validation.
+
+    Args:
+        jwt_verifier: The wrapped JWT verifier for non-API-key tokens.
+            When ``None``, only API-key tokens are accepted; all other
+            Bearer tokens are rejected at the transport layer (used when
+            ``MCP_AUTH_ENABLED=False`` but ``FAB_API_KEY_ENABLED=True``).
+        api_key_prefixes: List of prefixes that identify API key tokens
+            (e.g. ``["sst_"]``).
+    """
+
+    def __init__(
+        self,
+        jwt_verifier: TokenVerifier | None,
+        api_key_prefixes: list[str],
+    ) -> None:
+        super().__init__(
+            base_url=getattr(jwt_verifier, "base_url", None),
+            required_scopes=getattr(jwt_verifier, "required_scopes", None) or 
[],
+        )
+        self._jwt_verifier = jwt_verifier
+        self._api_key_prefixes = tuple(api_key_prefixes)
+

Review Comment:
   Validation is in `__init__`: non-string and empty/whitespace entries are 
filtered via `[p for p in api_key_prefixes if isinstance(p, str) and 
p.strip()]`, invalid entries are logged (count only, not values) as a warning, 
and the clean list is stored as `self._api_key_prefixes`.



##########
superset/mcp_service/composite_token_verifier.py:
##########
@@ -0,0 +1,113 @@
+# 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.
+
+"""
+Composite token verifier for MCP authentication.
+
+Routes Bearer tokens to the appropriate verifier based on prefix:
+- Tokens matching FAB_API_KEY_PREFIXES (e.g. ``sst_``) are passed through
+  to the Flask layer where ``_resolve_user_from_api_key()`` handles
+  actual validation via FAB SecurityManager.
+- All other tokens are delegated to the wrapped JWT verifier (when one is
+  configured); when no JWT verifier is configured, non-API-key tokens are
+  rejected at the transport layer.
+"""
+
+import logging
+
+from fastmcp.server.auth import AccessToken
+from fastmcp.server.auth.providers.jwt import TokenVerifier
+
+logger = logging.getLogger(__name__)
+
+# Namespaced claim that flags an AccessToken as an API-key pass-through.
+# Namespacing avoids collision with custom claims an external IdP might
+# happen to mint on a JWT — a plain ``_api_key_passthrough`` claim could
+# be silently misidentified as a Superset API-key request.
+API_KEY_PASSTHROUGH_CLAIM = "_superset_mcp_api_key_passthrough"
+
+
+class CompositeTokenVerifier(TokenVerifier):
+    """Routes Bearer tokens between API key pass-through and JWT verification.
+
+    API key tokens (identified by prefix) are accepted at the transport layer
+    with a marker claim so that ``_resolve_user_from_jwt_context()`` can
+    detect them and fall through to ``_resolve_user_from_api_key()`` for
+    actual validation.
+
+    Args:
+        jwt_verifier: The wrapped JWT verifier for non-API-key tokens.
+            When ``None``, only API-key tokens are accepted; all other
+            Bearer tokens are rejected at the transport layer (used when
+            ``MCP_AUTH_ENABLED=False`` but ``FAB_API_KEY_ENABLED=True``).
+        api_key_prefixes: List of prefixes that identify API key tokens
+            (e.g. ``["sst_"]``).
+    """
+
+    def __init__(
+        self,
+        jwt_verifier: TokenVerifier | None,
+        api_key_prefixes: list[str],
+    ) -> None:
+        super().__init__(
+            base_url=getattr(jwt_verifier, "base_url", None),
+            required_scopes=getattr(jwt_verifier, "required_scopes", None) or 
[],
+        )
+        self._jwt_verifier = jwt_verifier
+        valid: list[str] = [
+            p for p in api_key_prefixes if isinstance(p, str) and p.strip()
+        ]
+        invalid = [p for p in api_key_prefixes if p not in valid]
+        if invalid:
+            logger.warning(
+                "FAB_API_KEY_PREFIXES contains invalid entries (ignored): %r", 
invalid

Review Comment:
   This is a CodeQL false positive — `len(invalid)` logs only the integer count 
of invalid entries, not the values themselves. The actual prefix strings are 
never logged. The surrounding comment at line 76-77 documents this explicitly.



##########
tests/unit_tests/mcp_service/test_auth_api_key.py:
##########
@@ -54,100 +84,110 @@ def _disable_api_keys(app):
         app.config["MCP_DEV_USERNAME"] = old_dev
 
 
+@contextmanager
+def _mock_sm_ctx(app: SupersetApp, mock_sm: MagicMock):
+    """Push an app context with g.user cleared and appbuilder.sm mocked.
+
+    Defaults find_user_with_relationships to None so JWT/dev-user lookups
+    that hit the SM (via load_user_with_relationships) behave as "user not
+    found" without a real DB, matching the pre-refactor db.session behavior.
+    Tests that need a specific return value should set it on mock_sm directly.
+    """
+    mock_sm.find_user_with_relationships.return_value = None

Review Comment:
   `_mock_sm_ctx` no longer accesses `find_user_with_relationships` — it only 
sets `app.appbuilder.sm = mock_sm`. The MagicMock(spec=[]) test 
(test_fab_without_validate_method_raises) relies on `hasattr(sm, 
"validate_api_key")` returning `False` for a spec-empty mock, which is correct. 
Tests that need `load_user_with_relationships` to return specific values patch 
it directly via 
`patch("superset.mcp_service.auth.load_user_with_relationships", ...)` as 
described in the `_patch_load_user_not_found()` helper.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to