mirror of
https://github.com/apache/superset.git
synced 2026-06-23 08:29:18 +00:00
Compare commits
1 Commits
dependabot
...
fix/mcp-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0475416584 |
@@ -24,6 +24,10 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### MCP service requires `MCP_JWT_AUDIENCE` when JWT auth is enabled
|
||||
|
||||
When the MCP service has JWT auth enabled (`MCP_AUTH_ENABLED = True`), an audience must be configured via `MCP_JWT_AUDIENCE` so issued tokens are bound to this service. The service now fails to start with a clear configuration error when the audience is unset, instead of starting with audience validation skipped. Deployments that enable MCP JWT auth must set `MCP_JWT_AUDIENCE` to the audience value their identity provider issues for the MCP service. API-key-only MCP deployments (JWT auth disabled) are unaffected.
|
||||
|
||||
### Pivot table First/Last aggregations follow data order
|
||||
|
||||
The pivot table chart's `First` and `Last` aggregations now return the first and last value in data (query result) order, instead of effectively returning the minimum and maximum. Existing pivot tables that use these aggregations for totals/subtotals may show different values after upgrading. For deterministic results, ensure the underlying query has a stable sort order.
|
||||
|
||||
@@ -33,6 +33,17 @@ from superset.mcp_service.jwt_verifier import DetailedJWTVerifier, MCPJWTVerifie
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MCPAuthConfigError(ValueError):
|
||||
"""Raised when MCP auth is enabled but configured in an unusable state.
|
||||
|
||||
Distinct from the generic build errors (e.g. malformed key material) that
|
||||
the auth bootstrap intentionally swallows: a configuration error of this
|
||||
kind must propagate so the MCP service fails to start rather than silently
|
||||
coming up without the protection the operator asked for.
|
||||
"""
|
||||
|
||||
|
||||
# MCP Service Configuration
|
||||
# Note: MCP_DEV_USERNAME MUST be configured in superset_config.py
|
||||
# There is no default value - the service will fail if not set
|
||||
@@ -360,6 +371,20 @@ def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]:
|
||||
if not (auth_enabled or api_key_enabled):
|
||||
return None
|
||||
|
||||
# When JWT auth is enabled, an audience must be configured so issued tokens
|
||||
# are bound to this service. Without it the verifier accepts any otherwise
|
||||
# valid same-issuer token, regardless of which service it was minted for.
|
||||
# Treat a missing audience as a fatal configuration error so the service
|
||||
# fails to start instead of coming up in a permissive state — the
|
||||
# surrounding bootstrap would otherwise turn a None/raised provider into an
|
||||
# unauthenticated server.
|
||||
if auth_enabled and not app.config.get("MCP_JWT_AUDIENCE"):
|
||||
raise MCPAuthConfigError(
|
||||
"MCP_JWT_AUDIENCE must be set when MCP_AUTH_ENABLED is True so that "
|
||||
"tokens are bound to this service. Set MCP_JWT_AUDIENCE to the "
|
||||
"audience value your identity provider issues for the MCP service."
|
||||
)
|
||||
|
||||
jwt_verifier: Any | None = None
|
||||
|
||||
if auth_enabled:
|
||||
|
||||
@@ -750,6 +750,7 @@ def _create_auth_provider(flask_app: Any) -> Any | None:
|
||||
):
|
||||
from superset.mcp_service.mcp_config import (
|
||||
create_default_mcp_auth_factory,
|
||||
MCPAuthConfigError,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -758,6 +759,12 @@ def _create_auth_provider(flask_app: Any) -> Any | None:
|
||||
"Auth provider created from default factory: %s",
|
||||
type(auth_provider).__name__ if auth_provider else "None",
|
||||
)
|
||||
except MCPAuthConfigError:
|
||||
# A misconfiguration that must fail closed: re-raise so the service
|
||||
# refuses to start rather than falling through to an unauthenticated
|
||||
# server. The message is operator-facing config guidance and carries
|
||||
# no secret material.
|
||||
raise
|
||||
except Exception:
|
||||
# Do not log the exception — it may contain secrets
|
||||
logger.error("Failed to create auth provider from default factory")
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.mcp_service.app import get_default_instructions, init_fastmcp_server
|
||||
|
||||
|
||||
@@ -383,6 +385,7 @@ def test_create_default_mcp_auth_factory_jwt_with_keys():
|
||||
"MCP_AUTH_ENABLED": True,
|
||||
"MCP_API_KEY_ENABLED": False,
|
||||
"FAB_API_KEY_ENABLED": False,
|
||||
"MCP_JWT_AUDIENCE": "superset-mcp",
|
||||
"MCP_JWT_SECRET": "shhh",
|
||||
}.get(key, default)
|
||||
|
||||
@@ -405,6 +408,7 @@ def test_create_default_mcp_auth_factory_jwt_enabled_without_keys_returns_none()
|
||||
"MCP_AUTH_ENABLED": True,
|
||||
"MCP_API_KEY_ENABLED": False,
|
||||
"FAB_API_KEY_ENABLED": False,
|
||||
"MCP_JWT_AUDIENCE": "superset-mcp",
|
||||
}.get(key, default)
|
||||
|
||||
with patch("superset.mcp_service.mcp_config.logger") as mock_logger:
|
||||
@@ -423,6 +427,7 @@ def test_create_default_mcp_auth_factory_jwt_build_failure_returns_none():
|
||||
"MCP_AUTH_ENABLED": True,
|
||||
"MCP_API_KEY_ENABLED": False,
|
||||
"FAB_API_KEY_ENABLED": False,
|
||||
"MCP_JWT_AUDIENCE": "superset-mcp",
|
||||
"MCP_JWT_SECRET": "shhh",
|
||||
}.get(key, default)
|
||||
|
||||
@@ -437,3 +442,45 @@ def test_create_default_mcp_auth_factory_jwt_build_failure_returns_none():
|
||||
|
||||
assert result is None
|
||||
mock_logger.error.assert_called_once()
|
||||
|
||||
|
||||
def test_create_default_mcp_auth_factory_requires_audience_when_jwt_enabled():
|
||||
"""MCP_AUTH_ENABLED=True without MCP_JWT_AUDIENCE fails closed.
|
||||
|
||||
A missing audience must raise MCPAuthConfigError (rather than returning a
|
||||
permissive verifier) so the bootstrap refuses to start the service instead
|
||||
of accepting same-issuer tokens minted for other services.
|
||||
"""
|
||||
from superset.mcp_service.mcp_config import (
|
||||
create_default_mcp_auth_factory,
|
||||
MCPAuthConfigError,
|
||||
)
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.config.get.side_effect = lambda key, default=None: {
|
||||
"MCP_AUTH_ENABLED": True,
|
||||
"MCP_API_KEY_ENABLED": False,
|
||||
"FAB_API_KEY_ENABLED": False,
|
||||
"MCP_JWT_SECRET": "shhh",
|
||||
}.get(key, default)
|
||||
|
||||
with pytest.raises(MCPAuthConfigError):
|
||||
create_default_mcp_auth_factory(mock_app)
|
||||
|
||||
|
||||
def test_create_default_mcp_auth_factory_audience_not_required_for_api_key_only():
|
||||
"""API-key-only auth (JWT disabled) does not require MCP_JWT_AUDIENCE."""
|
||||
from superset.mcp_service.composite_token_verifier import CompositeTokenVerifier
|
||||
from superset.mcp_service.mcp_config import create_default_mcp_auth_factory
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.config.get.side_effect = lambda key, default=None: {
|
||||
"MCP_AUTH_ENABLED": False,
|
||||
"MCP_API_KEY_ENABLED": True,
|
||||
"FAB_API_KEY_PREFIXES": ["sst_"],
|
||||
"MCP_REQUIRED_SCOPES": [],
|
||||
}.get(key, default)
|
||||
|
||||
result = create_default_mcp_auth_factory(mock_app)
|
||||
|
||||
assert isinstance(result, CompositeTokenVerifier)
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_create_event_store_returns_none_when_no_redis_url():
|
||||
"""EventStore returns None when no Redis URL configured (single-pod mode)."""
|
||||
@@ -181,3 +183,29 @@ def test_create_auth_provider_uses_default_factory_for_mcp_api_key_only() -> Non
|
||||
|
||||
assert result is auth_provider
|
||||
create_default_mcp_auth_factory.assert_called_once_with(flask_app)
|
||||
|
||||
|
||||
def test_create_auth_provider_propagates_auth_config_error() -> None:
|
||||
"""A fatal auth config error must propagate, not fall through to no auth.
|
||||
|
||||
The default factory raises MCPAuthConfigError for an unusable auth
|
||||
configuration. _create_auth_provider must re-raise it so the service fails
|
||||
to start instead of silently returning None (which would run unauthenticated).
|
||||
"""
|
||||
from superset.mcp_service.mcp_config import MCPAuthConfigError
|
||||
from superset.mcp_service.server import _create_auth_provider
|
||||
|
||||
flask_app = MagicMock()
|
||||
flask_app.config.get.side_effect = lambda key, default=None: {
|
||||
"MCP_AUTH_FACTORY": None,
|
||||
"MCP_AUTH_ENABLED": True,
|
||||
"MCP_API_KEY_ENABLED": False,
|
||||
"FAB_API_KEY_ENABLED": False,
|
||||
}.get(key, default)
|
||||
|
||||
with patch(
|
||||
"superset.mcp_service.mcp_config.create_default_mcp_auth_factory",
|
||||
side_effect=MCPAuthConfigError("MCP_JWT_AUDIENCE must be set"),
|
||||
):
|
||||
with pytest.raises(MCPAuthConfigError):
|
||||
_create_auth_provider(flask_app)
|
||||
|
||||
Reference in New Issue
Block a user