mirror of
https://github.com/apache/superset.git
synced 2026-07-12 01:35:36 +00:00
686 lines
24 KiB
Python
686 lines
24 KiB
Python
# 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.
|
|
|
|
"""Tests for embedded guest-token authentication on the MCP transport.
|
|
|
|
Covers the three layers of the feature:
|
|
- ``GuestTokenVerifier`` (transport): validates a guest token by reusing core's
|
|
guest-token machinery and emits a marked ``AccessToken``.
|
|
- ``CompositeTokenVerifier`` routing: guests are tried before the JWT verifier.
|
|
- ``_resolve_user_from_jwt_context`` (resolution): builds the ``GuestUser`` from
|
|
the verified claims, and ignores look-alike tokens lacking the marker.
|
|
- The guest deny-list for sensitive enumeration tools.
|
|
"""
|
|
|
|
from contextlib import contextmanager
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import jwt
|
|
import pytest
|
|
from fastmcp.server.auth import AccessToken
|
|
from flask import g
|
|
|
|
from superset.app import SupersetApp
|
|
from superset.constants import CHANGE_ME_GUEST_TOKEN_JWT_SECRET
|
|
from superset.mcp_service.auth import (
|
|
_DEFAULT_GUEST_ALLOWED_TOOLS,
|
|
_resolve_user_from_jwt_context,
|
|
_tool_denied_for_principal,
|
|
check_tool_permission,
|
|
CLASS_PERMISSION_ATTR,
|
|
is_tool_visible_to_current_user,
|
|
METHOD_PERMISSION_ATTR,
|
|
)
|
|
from superset.mcp_service.composite_token_verifier import CompositeTokenVerifier
|
|
from superset.mcp_service.guest_token_verifier import (
|
|
GUEST_TOKEN_CLAIM,
|
|
GuestTokenVerifier,
|
|
)
|
|
from superset.mcp_service.mcp_config import (
|
|
_is_mcp_guest_auth_enabled,
|
|
_validate_guest_config,
|
|
MCPAuthConfigError,
|
|
)
|
|
from superset.security.guest_token import GuestUser
|
|
|
|
|
|
def _access_token(client_id: str, claims: dict[str, Any]) -> AccessToken:
|
|
"""Build a fastmcp AccessToken for routing tests."""
|
|
fake_token = "fake-token" # noqa: S105 — test fixture, not a real credential
|
|
return AccessToken(token=fake_token, client_id=client_id, scopes=[], claims=claims)
|
|
|
|
|
|
def _parsed_guest_claims(**overrides: Any) -> dict[str, Any]:
|
|
"""A decoded guest-token claims dict shaped like core's mint output."""
|
|
claims = {
|
|
"user": {"username": "embed-guest"},
|
|
"resources": [{"type": "dashboard", "id": "abc-uuid"}],
|
|
"rls_rules": [],
|
|
"iat": 1,
|
|
"exp": 9_999_999_999,
|
|
"aud": "superset",
|
|
"type": "guest",
|
|
}
|
|
claims.update(overrides)
|
|
return claims
|
|
|
|
|
|
class _FakeApp:
|
|
"""Minimal Flask-app stand-in for unit-testing GuestTokenVerifier."""
|
|
|
|
def __init__(self, config: dict[str, Any], sm: Any) -> None:
|
|
self.config = config
|
|
self.appbuilder = SimpleNamespace(sm=sm)
|
|
|
|
@contextmanager
|
|
def app_context(self) -> Any:
|
|
yield
|
|
|
|
|
|
def _make_guest_verifier(
|
|
*,
|
|
parsed: dict[str, Any] | None = None,
|
|
parse_error: Exception | None = None,
|
|
revoked: bool = False,
|
|
role_exists: bool = True,
|
|
mcp_enabled: bool = True,
|
|
) -> tuple[GuestTokenVerifier, MagicMock]:
|
|
sm = MagicMock()
|
|
if parse_error is not None:
|
|
sm.parse_jwt_guest_token.side_effect = parse_error
|
|
else:
|
|
sm.parse_jwt_guest_token.return_value = (
|
|
_parsed_guest_claims() if parsed is None else parsed
|
|
)
|
|
sm._is_guest_token_revoked.return_value = revoked
|
|
sm.find_role.return_value = object() if role_exists else None
|
|
config = {
|
|
"MCP_EMBEDDED_GUEST_AUTH_ENABLED": mcp_enabled,
|
|
"GUEST_ROLE_NAME": "Public",
|
|
}
|
|
return GuestTokenVerifier(app=_FakeApp(config, sm)), sm
|
|
|
|
|
|
# -- GuestTokenVerifier --
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_guest_verifier_accepts_valid_token() -> None:
|
|
verifier, _ = _make_guest_verifier()
|
|
with patch("superset.is_feature_enabled", return_value=True):
|
|
token = await verifier.verify_token("raw-guest-token")
|
|
|
|
assert token is not None
|
|
assert token.client_id == "guest"
|
|
assert token.claims[GUEST_TOKEN_CLAIM] is True
|
|
# The parsed guest claims are carried through for resolution.
|
|
assert token.claims["user"] == {"username": "embed-guest"}
|
|
assert token.claims["type"] == "guest"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_guest_verifier_noop_when_mcp_flag_off() -> None:
|
|
verifier, sm = _make_guest_verifier(mcp_enabled=False)
|
|
with patch("superset.is_feature_enabled", return_value=True):
|
|
token = await verifier.verify_token("raw-guest-token")
|
|
|
|
assert token is None
|
|
# Short-circuits before doing any parsing work.
|
|
sm.parse_jwt_guest_token.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_guest_verifier_defers_when_embedded_disabled() -> None:
|
|
verifier, _ = _make_guest_verifier()
|
|
with patch("superset.is_feature_enabled", return_value=False):
|
|
token = await verifier.verify_token("raw-guest-token")
|
|
|
|
assert token is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_guest_verifier_defers_on_bad_signature() -> None:
|
|
verifier, _ = _make_guest_verifier(parse_error=ValueError("bad signature"))
|
|
with patch("superset.is_feature_enabled", return_value=True):
|
|
token = await verifier.verify_token("not-a-guest-token")
|
|
|
|
assert token is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_guest_verifier_rejects_non_guest_type() -> None:
|
|
verifier, _ = _make_guest_verifier(parsed=_parsed_guest_claims(type="access"))
|
|
with patch("superset.is_feature_enabled", return_value=True):
|
|
token = await verifier.verify_token("raw-token")
|
|
|
|
assert token is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_guest_verifier_rejects_missing_structural_claims() -> None:
|
|
bad = _parsed_guest_claims()
|
|
del bad["resources"]
|
|
verifier, _ = _make_guest_verifier(parsed=bad)
|
|
with patch("superset.is_feature_enabled", return_value=True):
|
|
token = await verifier.verify_token("raw-token")
|
|
|
|
assert token is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_guest_verifier_rejects_revoked_token() -> None:
|
|
verifier, _ = _make_guest_verifier(revoked=True)
|
|
with patch("superset.is_feature_enabled", return_value=True):
|
|
token = await verifier.verify_token("raw-guest-token")
|
|
|
|
assert token is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_guest_verifier_rejects_when_guest_role_missing() -> None:
|
|
verifier, _ = _make_guest_verifier(role_exists=False)
|
|
with patch("superset.is_feature_enabled", return_value=True):
|
|
token = await verifier.verify_token("raw-guest-token")
|
|
|
|
assert token is None
|
|
|
|
|
|
# -- CompositeTokenVerifier routing --
|
|
|
|
|
|
class _StubVerifier:
|
|
base_url = None
|
|
required_scopes: list[str] = []
|
|
|
|
def __init__(self, return_value: Any) -> None:
|
|
self._return_value = return_value
|
|
self.called = False
|
|
|
|
async def verify_token(self, token: str) -> Any:
|
|
self.called = True
|
|
return self._return_value
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_composite_tries_guest_before_jwt() -> None:
|
|
guest_at = _access_token("guest", {GUEST_TOKEN_CLAIM: True})
|
|
guest = _StubVerifier(guest_at)
|
|
jwt = _StubVerifier(_access_token("idp", {}))
|
|
composite = CompositeTokenVerifier(
|
|
jwt_verifier=jwt, api_key_prefixes=[], app=None, guest_verifier=guest
|
|
)
|
|
|
|
result = await composite.verify_token("some-token")
|
|
|
|
assert result is guest_at
|
|
assert guest.called is True
|
|
assert jwt.called is False # guest short-circuits the JWT path
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_composite_falls_through_to_jwt_for_non_guest() -> None:
|
|
jwt_result = _access_token("idp", {})
|
|
guest = _StubVerifier(None) # not a guest token
|
|
jwt = _StubVerifier(jwt_result)
|
|
composite = CompositeTokenVerifier(
|
|
jwt_verifier=jwt, api_key_prefixes=[], app=None, guest_verifier=guest
|
|
)
|
|
|
|
result = await composite.verify_token("some-token")
|
|
|
|
assert result is jwt_result
|
|
assert guest.called is True
|
|
assert jwt.called is True
|
|
|
|
|
|
# -- _resolve_user_from_jwt_context --
|
|
|
|
|
|
def test_resolve_builds_guest_user_from_token(app: SupersetApp) -> None:
|
|
token = MagicMock()
|
|
token.claims = {GUEST_TOKEN_CLAIM: True, **_parsed_guest_claims()}
|
|
token.client_id = "guest"
|
|
fake_guest = MagicMock()
|
|
fake_guest.username = "embed-guest"
|
|
|
|
with app.app_context():
|
|
with (
|
|
patch.dict(app.config, {"MCP_EMBEDDED_GUEST_AUTH_ENABLED": True}),
|
|
patch("fastmcp.server.dependencies.get_access_token", return_value=token),
|
|
patch("superset.mcp_service.auth.is_feature_enabled", return_value=True),
|
|
patch("superset.mcp_service.auth.security_manager") as mock_sm,
|
|
):
|
|
mock_sm.get_guest_user_from_token = MagicMock(return_value=fake_guest)
|
|
result = _resolve_user_from_jwt_context(app)
|
|
|
|
assert result is fake_guest
|
|
# The internal marker must not leak into the GuestUser's token dict.
|
|
passed_token = mock_sm.get_guest_user_from_token.call_args.args[0]
|
|
assert GUEST_TOKEN_CLAIM not in passed_token
|
|
|
|
|
|
def test_resolve_ignores_guest_type_without_marker(app: SupersetApp) -> None:
|
|
"""An external IdP JWT that merely carries ``type==guest`` must NOT be
|
|
treated as an embedded guest — it lacks the namespaced marker and the
|
|
``client_id == "guest"`` signal, so normal resolution applies."""
|
|
mock_user = MagicMock()
|
|
mock_user.username = "alice"
|
|
mock_user.roles = []
|
|
mock_user.groups = []
|
|
token = MagicMock()
|
|
token.claims = {"type": "guest", "sub": "alice"}
|
|
token.client_id = "idp"
|
|
|
|
with app.app_context():
|
|
with (
|
|
patch("fastmcp.server.dependencies.get_access_token", return_value=token),
|
|
patch(
|
|
"superset.mcp_service.auth.load_user_with_relationships",
|
|
return_value=mock_user,
|
|
),
|
|
):
|
|
result = _resolve_user_from_jwt_context(app)
|
|
|
|
assert result is not None
|
|
assert result.username == "alice"
|
|
|
|
|
|
# -- Guest deny-list --
|
|
|
|
|
|
def _make_guest_user() -> GuestUser:
|
|
return GuestUser(
|
|
token={
|
|
"user": {"username": "g"},
|
|
"resources": [],
|
|
"rls_rules": [],
|
|
"iat": 1,
|
|
"exp": 9_999_999_999,
|
|
},
|
|
roles=[],
|
|
)
|
|
|
|
|
|
def test_tool_denied_for_principal_helper(app: SupersetApp) -> None:
|
|
denied = MagicMock()
|
|
denied.__name__ = "find_users" # not on the allow-list
|
|
allowed = MagicMock()
|
|
allowed.__name__ = "list_charts" # on the allow-list
|
|
|
|
with app.app_context():
|
|
# Non-guest: the guest allow-list never applies.
|
|
g.user = MagicMock(spec=[])
|
|
assert _tool_denied_for_principal(denied) is False
|
|
|
|
# Guest: only allow-listed tools pass; everything else is denied.
|
|
g.user = _make_guest_user()
|
|
assert _tool_denied_for_principal(denied) is True
|
|
assert _tool_denied_for_principal(allowed) is False
|
|
|
|
|
|
def test_default_allow_list_matches_config() -> None:
|
|
"""The auth.py default and the mcp_config.py default must stay in sync."""
|
|
from superset.mcp_service.mcp_config import MCP_GUEST_ALLOWED_TOOLS
|
|
|
|
assert set(_DEFAULT_GUEST_ALLOWED_TOOLS) == set(MCP_GUEST_ALLOWED_TOOLS)
|
|
|
|
|
|
@pytest.mark.parametrize("tool_name", sorted(_DEFAULT_GUEST_ALLOWED_TOOLS))
|
|
def test_allow_listed_tools_permitted_for_guest(
|
|
app: SupersetApp, tool_name: str
|
|
) -> None:
|
|
"""Every tool on the default allow-list is reachable by a guest."""
|
|
func = MagicMock()
|
|
func.__name__ = tool_name
|
|
|
|
with app.app_context():
|
|
g.user = _make_guest_user()
|
|
assert _tool_denied_for_principal(func) is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"tool_name",
|
|
[
|
|
# user-directory / security-config
|
|
"find_users",
|
|
"get_user_info",
|
|
"list_users",
|
|
"get_role_info",
|
|
"list_roles",
|
|
"get_instance_info",
|
|
# data-model internals
|
|
"get_dataset_info",
|
|
"list_datasets",
|
|
"query_dataset",
|
|
"get_database_info",
|
|
"get_chart_sql",
|
|
"list_rls_filters",
|
|
# newly-covered enumeration tools (tags/tasks/annotations/reports/queries)
|
|
"list_tags",
|
|
"list_tasks",
|
|
"list_annotation_layers",
|
|
"list_reports",
|
|
"list_queries",
|
|
"list_saved_queries",
|
|
# mutating tools
|
|
"execute_sql",
|
|
"create_dataset",
|
|
"update_dashboard",
|
|
"generate_chart",
|
|
],
|
|
)
|
|
def test_non_allow_listed_tools_denied_to_guest(
|
|
app: SupersetApp, tool_name: str
|
|
) -> None:
|
|
"""Default-deny: any tool not on the allow-list is blocked for a guest,
|
|
regardless of its RBAC class (holds with RBAC off)."""
|
|
func = MagicMock()
|
|
func.__name__ = tool_name
|
|
|
|
with app.app_context():
|
|
g.user = _make_guest_user()
|
|
assert _tool_denied_for_principal(func) is True
|
|
|
|
|
|
def test_guest_denied_tool_blocked_at_call_time(app: SupersetApp) -> None:
|
|
func = MagicMock()
|
|
func.__name__ = "find_users"
|
|
|
|
with app.app_context():
|
|
g.user = _make_guest_user()
|
|
# Denied regardless of MCP_RBAC_ENABLED (it runs before the gate).
|
|
assert check_tool_permission(func) is False
|
|
|
|
|
|
def test_guest_denied_tool_hidden_from_listing(app: SupersetApp) -> None:
|
|
tool = MagicMock()
|
|
tool.fn = MagicMock()
|
|
tool.fn.__name__ = "get_instance_info"
|
|
|
|
with app.app_context():
|
|
g.user = _make_guest_user()
|
|
assert is_tool_visible_to_current_user(tool) is False
|
|
|
|
|
|
def test_guest_allowed_tool_permitted_with_rbac(app: SupersetApp) -> None:
|
|
"""A guest passes the allow-list and is granted an allow-listed tool through
|
|
the full RBAC chain (allow-list runs, RBAC grants, scopes allow)."""
|
|
func = MagicMock()
|
|
func.__name__ = "list_charts"
|
|
setattr(func, CLASS_PERMISSION_ATTR, "Chart")
|
|
setattr(func, METHOD_PERMISSION_ATTR, "read")
|
|
|
|
with app.app_context():
|
|
with (
|
|
patch.dict(app.config, {"MCP_RBAC_ENABLED": True}),
|
|
patch("superset.mcp_service.auth.security_manager") as mock_sm,
|
|
patch("superset.mcp_service.auth._token_scope_allows", return_value=True),
|
|
):
|
|
mock_sm.can_access = MagicMock(return_value=True)
|
|
g.user = _make_guest_user()
|
|
assert check_tool_permission(func) is True
|
|
|
|
|
|
def test_allowlist_string_misconfig_falls_back_to_default(app: SupersetApp) -> None:
|
|
"""A misconfigured string allow-list must not cause substring matching; the
|
|
type guard falls back to the safe default set (fail-closed)."""
|
|
func = MagicMock()
|
|
func.__name__ = "chart" # substring of allow-list entries like "get_chart_info"
|
|
|
|
with app.app_context():
|
|
with patch.dict(
|
|
app.config, {"MCP_GUEST_ALLOWED_TOOLS": "get_chart_info,list_charts"}
|
|
):
|
|
g.user = _make_guest_user()
|
|
# Naive substring `in` would wrongly ALLOW "chart"; the guard falls
|
|
# back to the default set, where "chart" is absent, so it is denied.
|
|
assert _tool_denied_for_principal(func) is True
|
|
# A genuinely allow-listed tool is still permitted via the default.
|
|
func.__name__ = "get_chart_info"
|
|
assert _tool_denied_for_principal(func) is False
|
|
|
|
|
|
def test_guest_allowed_tools_operator_override(app: SupersetApp) -> None:
|
|
"""An operator can widen the guest allow-list via config."""
|
|
func = MagicMock()
|
|
func.__name__ = "get_dataset_info" # not on the default allow-list
|
|
|
|
with app.app_context():
|
|
g.user = _make_guest_user()
|
|
assert _tool_denied_for_principal(func) is True
|
|
with patch.dict(app.config, {"MCP_GUEST_ALLOWED_TOOLS": {"get_dataset_info"}}):
|
|
assert _tool_denied_for_principal(func) is False
|
|
|
|
|
|
def test_restricted_tool_policy_hook_restricts_non_guest(app: SupersetApp) -> None:
|
|
"""A custom MCP_RESTRICTED_TOOL_POLICY can restrict a non-guest principal,
|
|
proving the allow-list layer is principal-agnostic (guests are just the
|
|
default). The policy returns an allow-list; non-listed tools are denied."""
|
|
allowed = MagicMock()
|
|
allowed.__name__ = "list_charts"
|
|
denied = MagicMock()
|
|
denied.__name__ = "execute_sql"
|
|
|
|
def policy(user: Any) -> frozenset[str] | None:
|
|
return frozenset({"list_charts"})
|
|
|
|
with app.app_context():
|
|
with patch.dict(app.config, {"MCP_RESTRICTED_TOOL_POLICY": policy}):
|
|
g.user = MagicMock(spec=[]) # an ordinary (non-guest) principal
|
|
assert _tool_denied_for_principal(allowed) is False
|
|
assert _tool_denied_for_principal(denied) is True
|
|
|
|
|
|
def test_restricted_tool_policy_hook_none_means_unrestricted(app: SupersetApp) -> None:
|
|
"""A policy returning None leaves the principal governed by RBAC (not blocked
|
|
by the allow-list layer)."""
|
|
func = MagicMock()
|
|
func.__name__ = "execute_sql"
|
|
|
|
def policy(user: Any) -> frozenset[str] | None:
|
|
return None
|
|
|
|
with app.app_context():
|
|
with patch.dict(app.config, {"MCP_RESTRICTED_TOOL_POLICY": policy}):
|
|
g.user = _make_guest_user()
|
|
assert _tool_denied_for_principal(func) is False
|
|
|
|
|
|
def test_restricted_tool_policy_hook_bad_return_fails_closed(app: SupersetApp) -> None:
|
|
"""A misbehaving policy that returns a non-collection denies all tools."""
|
|
func = MagicMock()
|
|
func.__name__ = "get_chart_info"
|
|
|
|
def policy(user: Any) -> frozenset[str] | None:
|
|
return "list_charts" # type: ignore[return-value]
|
|
|
|
with app.app_context():
|
|
with patch.dict(app.config, {"MCP_RESTRICTED_TOOL_POLICY": policy}):
|
|
g.user = _make_guest_user()
|
|
assert _tool_denied_for_principal(func) is True
|
|
|
|
|
|
def test_restricted_tool_policy_hook_non_callable_uses_default(
|
|
app: SupersetApp,
|
|
) -> None:
|
|
"""A non-callable policy (a set is the natural mistake, since
|
|
MCP_GUEST_ALLOWED_TOOLS is a set) does not crash every call: it falls back to
|
|
the built-in default, so a guest stays default-denied for non-allow-listed
|
|
tools."""
|
|
func = MagicMock()
|
|
func.__name__ = "execute_sql"
|
|
|
|
with app.app_context():
|
|
with patch.dict(app.config, {"MCP_RESTRICTED_TOOL_POLICY": {"list_charts"}}):
|
|
g.user = _make_guest_user()
|
|
# No TypeError, and the guest is still restricted (execute_sql denied).
|
|
assert _tool_denied_for_principal(func) is True
|
|
|
|
|
|
# -- Hardening: expiry, forgery, and the resolution gate --
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_guest_verifier_defers_on_expired_token() -> None:
|
|
verifier, _ = _make_guest_verifier(parse_error=jwt.ExpiredSignatureError("expired"))
|
|
with patch("superset.is_feature_enabled", return_value=True):
|
|
token = await verifier.verify_token("expired-guest-token")
|
|
|
|
assert token is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_token_defers_on_missing_exp() -> None:
|
|
"""A successfully-parsed token with no usable ``exp`` must defer, not raise."""
|
|
bad = _parsed_guest_claims()
|
|
del bad["exp"]
|
|
verifier, _ = _make_guest_verifier(parsed=bad)
|
|
with patch("superset.is_feature_enabled", return_value=True):
|
|
token = await verifier.verify_token("raw-token")
|
|
|
|
assert token is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_composite_strips_guest_marker_from_jwt_token() -> None:
|
|
"""A crafted IdP JWT carrying the guest marker must have it stripped by the
|
|
composite, so it cannot be mistaken for a verified guest at resolution. The
|
|
strip rebuilds the token rather than mutating in place, so it holds even when
|
|
``AccessToken.claims`` is an immutable or copied mapping."""
|
|
forged = _access_token("guest", {GUEST_TOKEN_CLAIM: True, "sub": "attacker"})
|
|
jwt_verifier = _StubVerifier(forged)
|
|
composite = CompositeTokenVerifier(
|
|
jwt_verifier=jwt_verifier, api_key_prefixes=[], app=None, guest_verifier=None
|
|
)
|
|
|
|
result = await composite.verify_token("crafted-idp-jwt")
|
|
|
|
assert result is not None
|
|
assert GUEST_TOKEN_CLAIM not in result.claims
|
|
assert result.claims["sub"] == "attacker"
|
|
# Rebuilt, not mutated: the original token still carries the marker.
|
|
assert forged.claims[GUEST_TOKEN_CLAIM] is True
|
|
|
|
|
|
def test_resolve_rejects_guest_marker_when_guest_auth_disabled(
|
|
app: SupersetApp,
|
|
) -> None:
|
|
"""Even with the marker + client_id, a token is not treated as a guest when
|
|
embedded guest auth is disabled (defense against marker forgery)."""
|
|
token = MagicMock()
|
|
token.claims = {GUEST_TOKEN_CLAIM: True, **_parsed_guest_claims()}
|
|
token.client_id = "guest"
|
|
|
|
with app.app_context():
|
|
with (
|
|
patch.dict(app.config, {"MCP_EMBEDDED_GUEST_AUTH_ENABLED": False}),
|
|
patch("fastmcp.server.dependencies.get_access_token", return_value=token),
|
|
patch("superset.mcp_service.auth.is_feature_enabled", return_value=True),
|
|
):
|
|
result = _resolve_user_from_jwt_context(app)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_resolve_rejects_guest_marker_when_embedded_flag_off(app: SupersetApp) -> None:
|
|
"""The other half of the gate: with the marker + client_id + the MCP guest
|
|
flag on, a token is still not treated as a guest when the EMBEDDED_SUPERSET
|
|
feature flag is off (both gates are required)."""
|
|
token = MagicMock()
|
|
token.claims = {GUEST_TOKEN_CLAIM: True, **_parsed_guest_claims()}
|
|
token.client_id = "guest"
|
|
|
|
with app.app_context():
|
|
with (
|
|
patch.dict(app.config, {"MCP_EMBEDDED_GUEST_AUTH_ENABLED": True}),
|
|
patch("fastmcp.server.dependencies.get_access_token", return_value=token),
|
|
patch("superset.mcp_service.auth.is_feature_enabled", return_value=False),
|
|
):
|
|
result = _resolve_user_from_jwt_context(app)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_resolve_ignores_guest_marker_without_guest_client_id(app: SupersetApp) -> None:
|
|
"""The marker alone is not enough — ``client_id == "guest"`` is also required,
|
|
so a normal JWT that carries the marker resolves as its own user."""
|
|
mock_user = MagicMock()
|
|
mock_user.username = "alice"
|
|
mock_user.roles = []
|
|
mock_user.groups = []
|
|
token = MagicMock()
|
|
token.claims = {GUEST_TOKEN_CLAIM: True, "sub": "alice"}
|
|
token.client_id = "idp" # not "guest"
|
|
|
|
with app.app_context():
|
|
with (
|
|
patch.dict(app.config, {"MCP_EMBEDDED_GUEST_AUTH_ENABLED": True}),
|
|
patch("fastmcp.server.dependencies.get_access_token", return_value=token),
|
|
patch(
|
|
"superset.mcp_service.auth.load_user_with_relationships",
|
|
return_value=mock_user,
|
|
),
|
|
):
|
|
result = _resolve_user_from_jwt_context(app)
|
|
|
|
assert result is not None
|
|
assert result.username == "alice"
|
|
|
|
|
|
# -- guest config validation --
|
|
|
|
|
|
def test_validate_guest_config_raises_on_default_secret() -> None:
|
|
app = _FakeApp(
|
|
{"GUEST_TOKEN_JWT_SECRET": CHANGE_ME_GUEST_TOKEN_JWT_SECRET}, MagicMock()
|
|
)
|
|
with pytest.raises(MCPAuthConfigError, match="insecure default"):
|
|
_validate_guest_config(app)
|
|
|
|
|
|
def test_validate_guest_config_passes_with_strong_secret() -> None:
|
|
app = _FakeApp(
|
|
{
|
|
"GUEST_TOKEN_JWT_SECRET": "a-strong-shared-secret",
|
|
"GUEST_TOKEN_JWT_AUDIENCE": "superset",
|
|
},
|
|
MagicMock(),
|
|
)
|
|
_validate_guest_config(app) # does not raise
|
|
|
|
|
|
# -- guest auth enablement gate --
|
|
|
|
|
|
def test_mcp_guest_auth_disabled_when_embedded_flag_off() -> None:
|
|
app = _FakeApp({"MCP_EMBEDDED_GUEST_AUTH_ENABLED": True}, MagicMock())
|
|
with patch("superset.is_feature_enabled", return_value=False):
|
|
assert _is_mcp_guest_auth_enabled(app) is False
|
|
|
|
|
|
def test_mcp_guest_auth_enabled_when_flag_and_feature_on() -> None:
|
|
app = _FakeApp({"MCP_EMBEDDED_GUEST_AUTH_ENABLED": True}, MagicMock())
|
|
with patch("superset.is_feature_enabled", return_value=True):
|
|
assert _is_mcp_guest_auth_enabled(app) is True
|
|
|
|
|
|
def test_mcp_guest_auth_disabled_when_opt_in_off() -> None:
|
|
app = _FakeApp({"MCP_EMBEDDED_GUEST_AUTH_ENABLED": False}, MagicMock())
|
|
assert _is_mcp_guest_auth_enabled(app) is False
|