You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/03/24 17:37:34 UTC
(superset) branch master updated: feat(api-keys): add API key authentication via FAB SecurityManager (#37973)
This is an automated email from the ASF dual-hosted git repository.
misterg pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 811dcb37154 feat(api-keys): add API key authentication via FAB SecurityManager (#37973)
811dcb37154 is described below
commit 811dcb371547e8cb9b8f9587239a1f35d21aa0d7
Author: Amin Ghadersohi <am...@gmail.com>
AuthorDate: Tue Mar 24 13:37:26 2026 -0400
feat(api-keys): add API key authentication via FAB SecurityManager (#37973)
Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
Co-authored-by: Kamil Gabryjelski <ka...@gmail.com>
---
docs/static/feature-flags.json | 6 +
requirements/base.txt | 2 +-
requirements/development.txt | 2 +-
.../superset-ui-core/src/utils/featureFlags.ts | 1 +
.../src/features/apiKeys/ApiKeyCreateModal.tsx | 175 +++++++++++++++
.../src/features/apiKeys/ApiKeyList.tsx | 236 +++++++++++++++++++++
superset-frontend/src/pages/UserInfo/index.tsx | 26 ++-
superset/config.py | 11 +
superset/mcp_service/auth.py | 80 ++++++-
tests/unit_tests/mcp_service/test_auth_api_key.py | 227 ++++++++++++++++++++
tests/unit_tests/security/api_test.py | 25 ++-
11 files changed, 779 insertions(+), 12 deletions(-)
diff --git a/docs/static/feature-flags.json b/docs/static/feature-flags.json
index 5d7a86994f1..516115ac0ae 100644
--- a/docs/static/feature-flags.json
+++ b/docs/static/feature-flags.json
@@ -51,6 +51,12 @@
"lifecycle": "development",
"description": "Enable Superset extensions for custom functionality without modifying core"
},
+ {
+ "name": "FAB_API_KEY_ENABLED",
+ "default": false,
+ "lifecycle": "development",
+ "description": "Enable API key authentication via FAB SecurityManager When enabled, users can create/manage API keys in the User Info page"
+ },
{
"name": "GRANULAR_EXPORT_CONTROLS",
"default": false,
diff --git a/requirements/base.txt b/requirements/base.txt
index 240cf7d4eb1..14f5d8a6106 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -120,7 +120,7 @@ flask==2.3.3
# flask-session
# flask-sqlalchemy
# flask-wtf
-flask-appbuilder==5.1.0
+flask-appbuilder==5.2.0
# via
# apache-superset (pyproject.toml)
# apache-superset-core
diff --git a/requirements/development.txt b/requirements/development.txt
index 2b9fffd474b..50f5404f9c7 100644
--- a/requirements/development.txt
+++ b/requirements/development.txt
@@ -259,7 +259,7 @@ flask==2.3.3
# flask-sqlalchemy
# flask-testing
# flask-wtf
-flask-appbuilder==5.1.0
+flask-appbuilder==5.2.0
# via
# -c requirements/base-constraint.txt
# apache-superset
diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
index 18bc85af00c..8f21d83738b 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
+++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
@@ -53,6 +53,7 @@ export enum FeatureFlag {
EnableTemplateProcessing = 'ENABLE_TEMPLATE_PROCESSING',
EscapeMarkdownHtml = 'ESCAPE_MARKDOWN_HTML',
EstimateQueryCost = 'ESTIMATE_QUERY_COST',
+ FabApiKeyEnabled = 'FAB_API_KEY_ENABLED',
FilterBarClosedByDefault = 'FILTERBAR_CLOSED_BY_DEFAULT',
GlobalAsyncQueries = 'GLOBAL_ASYNC_QUERIES',
GlobalTaskFramework = 'GLOBAL_TASK_FRAMEWORK',
diff --git a/superset-frontend/src/features/apiKeys/ApiKeyCreateModal.tsx b/superset-frontend/src/features/apiKeys/ApiKeyCreateModal.tsx
new file mode 100644
index 00000000000..cc2cf7d4ac8
--- /dev/null
+++ b/superset-frontend/src/features/apiKeys/ApiKeyCreateModal.tsx
@@ -0,0 +1,175 @@
+/**
+ * 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.
+ */
+import { useEffect, useRef, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { css, useTheme } from '@apache-superset/core/theme';
+import { Alert } from '@apache-superset/core/components';
+import {
+ FormModal,
+ FormItem,
+ Input,
+ Button,
+ Modal,
+} from '@superset-ui/core/components';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
+
+interface ApiKeyCreateModalProps {
+ show: boolean;
+ onHide: () => void;
+ onSuccess: () => void;
+}
+
+interface FormValues {
+ name: string;
+}
+
+export function ApiKeyCreateModal({
+ show,
+ onHide,
+ onSuccess,
+}: ApiKeyCreateModalProps) {
+ const theme = useTheme();
+ const { addDangerToast, addSuccessToast } = useToasts();
+ const [createdKey, setCreatedKey] = useState<string | null>(null);
+ const [copied, setCopied] = useState(false);
+ const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+ useEffect(
+ () => () => {
+ if (copyTimerRef.current) {
+ clearTimeout(copyTimerRef.current);
+ }
+ },
+ [],
+ );
+
+ const handleFormSubmit = async (values: FormValues) => {
+ try {
+ const response = await SupersetClient.post({
+ endpoint: '/api/v1/security/api_keys/',
+ jsonPayload: values,
+ });
+ const key = response.json?.result?.key;
+ if (!key) {
+ throw new Error('API response did not include a key');
+ }
+ setCreatedKey(key);
+ addSuccessToast(t('API key created successfully'));
+ } catch (error) {
+ addDangerToast(t('Failed to create API key'));
+ throw error;
+ }
+ };
+
+ const handleCopyKey = async () => {
+ if (!createdKey) {
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(createdKey);
+ setCopied(true);
+ if (copyTimerRef.current) {
+ clearTimeout(copyTimerRef.current);
+ }
+ copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
+ } catch {
+ addDangerToast(t('Failed to copy API key to clipboard'));
+ }
+ };
+
+ const handleClose = () => {
+ if (createdKey) {
+ onSuccess();
+ }
+ setCreatedKey(null);
+ setCopied(false);
+ onHide();
+ };
+
+ if (createdKey) {
+ return (
+ <Modal
+ show={show}
+ onHide={handleClose}
+ title={t('API Key Created')}
+ maskClosable={false}
+ closable={false}
+ footer={
+ <Button type="primary" onClick={handleClose}>
+ {t('Done')}
+ </Button>
+ }
+ >
+ <Alert
+ type="warning"
+ message={t('Save this API key securely')}
+ description={t(
+ 'This is the only time you will see this key. Store it securely.',
+ )}
+ showIcon
+ css={css`
+ margin-bottom: ${theme.sizeUnit * 4}px;
+ `}
+ />
+ <div
+ css={css`
+ display: flex;
+ gap: ${theme.sizeUnit * 2}px;
+ align-items: center;
+ `}
+ >
+ <Input
+ value={createdKey}
+ readOnly
+ css={css`
+ flex: 1;
+ font-family: monospace;
+ `}
+ />
+ <Button onClick={handleCopyKey}>
+ {copied ? t('Copied!') : t('Copy')}
+ </Button>
+ </div>
+ </Modal>
+ );
+ }
+
+ return (
+ <FormModal
+ show={show}
+ onHide={handleClose}
+ title={t('Create API Key')}
+ onSave={() => {}}
+ formSubmitHandler={handleFormSubmit}
+ requiredFields={['name']}
+ >
+ <FormItem
+ name="name"
+ label={t('Name')}
+ rules={[{ required: true, message: t('API key name is required') }]}
+ >
+ <Input
+ name="name"
+ placeholder={t('e.g., CI/CD Pipeline, Analytics Script')}
+ />
+ </FormItem>
+ </FormModal>
+ );
+}
diff --git a/superset-frontend/src/features/apiKeys/ApiKeyList.tsx b/superset-frontend/src/features/apiKeys/ApiKeyList.tsx
new file mode 100644
index 00000000000..9b12962eb0a
--- /dev/null
+++ b/superset-frontend/src/features/apiKeys/ApiKeyList.tsx
@@ -0,0 +1,236 @@
+/**
+ * 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.
+ */
+import { useEffect, useRef, useState } from 'react';
+import { SupersetClient } from '@superset-ui/core';
+import { t } from '@apache-superset/core/translation';
+import { css, useTheme } from '@apache-superset/core/theme';
+import {
+ Button,
+ Table,
+ Modal,
+ Tag,
+ Tooltip,
+} from '@superset-ui/core/components';
+import { useToasts } from 'src/components/MessageToasts/withToasts';
+import { ApiKeyCreateModal } from './ApiKeyCreateModal';
+
+export interface ApiKey {
+ uuid: string;
+ name: string;
+ key_prefix: string;
+ active: boolean;
+ created_on: string;
+ expires_on: string | null;
+ revoked_on: string | null;
+ last_used_on: string | null;
+ scopes: string | null;
+}
+
+export function ApiKeyList() {
+ const theme = useTheme();
+ const { addDangerToast, addSuccessToast } = useToasts();
+ const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [showCreateModal, setShowCreateModal] = useState(false);
+ const fetchCounterRef = useRef(0);
+
+ async function fetchApiKeys() {
+ fetchCounterRef.current += 1;
+ const thisRequest = fetchCounterRef.current;
+ setLoading(true);
+ try {
+ const response = await SupersetClient.get({
+ endpoint: '/api/v1/security/api_keys/',
+ });
+ // Only apply results if this is still the most recent request
+ if (thisRequest === fetchCounterRef.current) {
+ setApiKeys(response.json.result || []);
+ }
+ } catch (error) {
+ if (thisRequest === fetchCounterRef.current) {
+ addDangerToast(t('Failed to fetch API keys'));
+ }
+ } finally {
+ if (thisRequest === fetchCounterRef.current) {
+ setLoading(false);
+ }
+ }
+ }
+
+ useEffect(() => {
+ fetchApiKeys();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ function handleRevokeKey(keyUuid: string) {
+ Modal.confirm({
+ title: t('Revoke API Key'),
+ content: t(
+ 'Are you sure you want to revoke this API key? This action cannot be undone.',
+ ),
+ okText: t('Revoke'),
+ okType: 'danger',
+ cancelText: t('Cancel'),
+ onOk: async () => {
+ try {
+ await SupersetClient.delete({
+ endpoint: `/api/v1/security/api_keys/${keyUuid}`,
+ });
+ addSuccessToast(t('API key revoked successfully'));
+ fetchApiKeys();
+ } catch (error) {
+ addDangerToast(t('Failed to revoke API key'));
+ }
+ },
+ });
+ }
+
+ const formatDate = (dateString: string | null) => {
+ if (!dateString) return '-';
+ return new Date(dateString).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ };
+
+ const getStatusBadge = (key: ApiKey) => {
+ if (key.revoked_on) {
+ return <Tag color="error">{t('Revoked')}</Tag>;
+ }
+ if (key.expires_on && new Date(key.expires_on) < new Date()) {
+ return <Tag color="warning">{t('Expired')}</Tag>;
+ }
+ if (!key.active) {
+ return <Tag color="default">{t('Inactive')}</Tag>;
+ }
+ return <Tag color="success">{t('Active')}</Tag>;
+ };
+
+ const columns = [
+ {
+ title: t('Name'),
+ dataIndex: 'name',
+ key: 'name',
+ },
+ {
+ title: t('Key Prefix'),
+ dataIndex: 'key_prefix',
+ key: 'key_prefix',
+ render: (prefix: string) => (
+ <code
+ css={css`
+ background: ${theme.colorFillSecondary};
+ padding: 2px 6px;
+ border-radius: 3px;
+ `}
+ >
+ {prefix}...
+ </code>
+ ),
+ },
+ {
+ title: t('Created'),
+ dataIndex: 'created_on',
+ key: 'created_on',
+ render: formatDate,
+ },
+ {
+ title: t('Last Used'),
+ dataIndex: 'last_used_on',
+ key: 'last_used_on',
+ render: formatDate,
+ },
+ {
+ title: t('Status'),
+ key: 'status',
+ render: (_: unknown, record: ApiKey) => getStatusBadge(record),
+ },
+ {
+ title: t('Actions'),
+ key: 'actions',
+ render: (_: unknown, record: ApiKey) => (
+ <>
+ {!record.revoked_on && record.active && (
+ <Tooltip title={t('Revoke this API key')}>
+ <Button
+ type="link"
+ danger
+ onClick={() => handleRevokeKey(record.uuid)}
+ >
+ {t('Revoke')}
+ </Button>
+ </Tooltip>
+ )}
+ </>
+ ),
+ },
+ ];
+
+ return (
+ <div>
+ <div
+ css={css`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: ${theme.sizeUnit * 4}px;
+ `}
+ >
+ <div>
+ <p
+ css={css`
+ margin-bottom: ${theme.sizeUnit * 2}px;
+ `}
+ >
+ {t('API keys allow scoped programmatic access to Superset.')}
+ </p>
+ <p
+ css={css`
+ margin-bottom: 0;
+ `}
+ >
+ {t('Keys are shown only once at creation. Store them securely.')}
+ </p>
+ </div>
+ <Button type="primary" onClick={() => setShowCreateModal(true)}>
+ {t('Create API Key')}
+ </Button>
+ </div>
+ <Table
+ columns={columns}
+ data={apiKeys}
+ loading={loading}
+ rowKey="uuid"
+ pagination={{ pageSize: 10 }}
+ />
+ {showCreateModal && (
+ <ApiKeyCreateModal
+ show={showCreateModal}
+ onHide={() => {
+ setShowCreateModal(false);
+ }}
+ onSuccess={() => {
+ fetchApiKeys();
+ }}
+ />
+ )}
+ </div>
+ );
+}
diff --git a/superset-frontend/src/pages/UserInfo/index.tsx b/superset-frontend/src/pages/UserInfo/index.tsx
index 92a54ad0e7d..a5d70f6ff86 100644
--- a/superset-frontend/src/pages/UserInfo/index.tsx
+++ b/superset-frontend/src/pages/UserInfo/index.tsx
@@ -19,7 +19,11 @@
import { useCallback, useEffect, useState } from 'react';
import { t } from '@apache-superset/core/translation';
-import { SupersetClient } from '@superset-ui/core';
+import {
+ SupersetClient,
+ FeatureFlag,
+ isFeatureEnabled,
+} from '@superset-ui/core';
import { css, useTheme, styled } from '@apache-superset/core/theme';
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
import { useToasts } from 'src/components/MessageToasts/withToasts';
@@ -30,6 +34,7 @@ import {
UserInfoResetPasswordModal,
} from 'src/features/userInfo/UserInfoModal';
import { Icons, Collapse } from '@superset-ui/core/components';
+import { ApiKeyList } from 'src/features/apiKeys/ApiKeyList';
const StyledHeader = styled.div`
${({ theme }) => css`
@@ -159,7 +164,16 @@ export function UserInfo({ user }: { user: UserWithPermissionsAndRoles }) {
<StyledLayout>
<StyledHeader>{t('Your user information')}</StyledHeader>
<DescriptionsContainer>
- <Collapse defaultActiveKey={['userInfo', 'personalInfo']} ghost>
+ <Collapse
+ defaultActiveKey={[
+ 'userInfo',
+ 'personalInfo',
+ ...(isFeatureEnabled(FeatureFlag.FabApiKeyEnabled)
+ ? ['apiKeys']
+ : []),
+ ]}
+ ghost
+ >
<Collapse.Panel
header={<DescriptionTitle>{t('User info')}</DescriptionTitle>}
key="userInfo"
@@ -205,6 +219,14 @@ export function UserInfo({ user }: { user: UserWithPermissionsAndRoles }) {
</Descriptions.Item>
</Descriptions>
</Collapse.Panel>
+ {isFeatureEnabled(FeatureFlag.FabApiKeyEnabled) && (
+ <Collapse.Panel
+ header={<DescriptionTitle>{t('API Keys')}</DescriptionTitle>}
+ key="apiKeys"
+ >
+ <ApiKeyList />
+ </Collapse.Panel>
+ )}
</Collapse>
</DescriptionsContainer>
{modalState.resetPassword && (
diff --git a/superset/config.py b/superset/config.py
index 4e79aff9395..0d9c2ed2e67 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -563,6 +563,10 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# in addition to relative timeshifts (e.g., "1 day ago")
# @lifecycle: development
"DATE_RANGE_TIMESHIFTS_ENABLED": False,
+ # Enable API key authentication via FAB SecurityManager
+ # When enabled, users can create/manage API keys in the User Info page
+ # @lifecycle: development
+ "FAB_API_KEY_ENABLED": False,
# Enable granular export controls (can_export_data, can_export_image,
# can_copy_clipboard) instead of the single can_csv permission
# @lifecycle: development
@@ -1638,6 +1642,13 @@ FAB_ADD_SECURITY_PERMISSION_VIEW = False
FAB_ADD_SECURITY_VIEW_MENU_VIEW = False
FAB_ADD_SECURITY_PERMISSION_VIEWS_VIEW = False
+# API Key Authentication via FAB SecurityManager
+# FAB reads this config directly to register the ApiKeyApi blueprint.
+# The FAB_API_KEY_ENABLED feature flag (in DEFAULT_FEATURE_FLAGS) controls
+# the frontend UI visibility independently.
+FAB_API_KEY_ENABLED = False
+FAB_API_KEY_PREFIXES = ["sst_"]
+
# The link to a page containing common errors and their resolutions
# It will be appended at the bottom of sql_lab errors.
TROUBLESHOOTING_LINK = ""
diff --git a/superset/mcp_service/auth.py b/superset/mcp_service/auth.py
index 41484d444cf..3579d2e8a55 100644
--- a/superset/mcp_service/auth.py
+++ b/superset/mcp_service/auth.py
@@ -19,20 +19,36 @@
Authentication and authorization hooks for MCP tools.
This module provides:
-- User authentication from JWT or configured dev user
+- User authentication from JWT, API key, or configured dev user
- RBAC permission checking aligned with Superset's REST API permissions
- Dataset access validation
- Session lifecycle management
The RBAC enforcement mirrors Flask-AppBuilder's @protect() decorator,
ensuring MCP tools respect the same permission model as the REST API.
+
+Supports multiple authentication methods:
+1. API Key authentication via FAB SecurityManager (configurable prefix)
+2. JWT token authentication (via FastMCP BearerAuthProvider)
+3. Development mode (MCP_DEV_USERNAME configuration)
+
+API Key Authentication:
+- Users create API keys via FAB's /api/v1/security/api_keys/ endpoints
+- Keys use configurable prefixes (FAB_API_KEY_PREFIXES, default: ["sst_"])
+- Keys are validated by FAB's SecurityManager.validate_api_key()
+- Keys inherit the user's roles and permissions via FAB's RBAC
+
+Configuration:
+- FAB_API_KEY_ENABLED: Flask config key to enable API key auth (default: False)
+- FAB_API_KEY_PREFIXES: Key prefixes (default: ["sst_"])
+- MCP_DEV_USERNAME: Fallback username for development
"""
import logging
from contextlib import AbstractContextManager
from typing import Any, Callable, TYPE_CHECKING, TypeVar
-from flask import g
+from flask import g, has_request_context
from flask_appbuilder.security.sqla.models import Group, User
if TYPE_CHECKING:
@@ -177,8 +193,9 @@ def get_user_from_request() -> User:
Get the current user for the MCP tool request.
Priority order:
- 1. g.user if already set (by Preset workspace middleware)
- 2. MCP_DEV_USERNAME from configuration (for development/testing)
+ 1. g.user if already set (by Preset workspace middleware or FastMCP auth)
+ 2. API key from Authorization header (via FAB SecurityManager)
+ 3. MCP_DEV_USERNAME from configuration (for development/testing)
Returns:
User object with roles and groups eagerly loaded
@@ -192,6 +209,55 @@ def get_user_from_request() -> User:
if hasattr(g, "user") and g.user:
return g.user
+ # Try API key authentication via FAB SecurityManager
+ # Only attempt when in a request context (not for MCP internal operations
+ # like tool discovery that run with only an application context)
+ # Use the Flask config key FAB_API_KEY_ENABLED (not the feature flag),
+ # because the config key controls whether FAB registers the API key
+ # endpoints and validation logic. The feature flag with the same name
+ # in DEFAULT_FEATURE_FLAGS only controls the frontend UI visibility.
+ if current_app.config.get("FAB_API_KEY_ENABLED", False) and has_request_context():
+ sm = current_app.appbuilder.sm
+ # _extract_api_key_from_request is FAB's internal method for reading
+ # the Bearer token from the Authorization header and matching prefixes.
+ # Not all FAB versions include this method, so guard with hasattr.
+ if not hasattr(sm, "_extract_api_key_from_request"):
+ logger.debug(
+ "FAB SecurityManager does not have _extract_api_key_from_request; "
+ "API key authentication is not available in this FAB version"
+ )
+ else:
+ api_key_string = sm._extract_api_key_from_request()
+ if api_key_string is not None:
+ if not hasattr(sm, "validate_api_key"):
+ logger.warning(
+ "FAB SecurityManager does not have validate_api_key; "
+ "cannot validate API key"
+ )
+ raise PermissionError(
+ "API key validation is not available in this FAB version."
+ )
+ user = sm.validate_api_key(api_key_string)
+ if user:
+ # Reload user with all relationships eagerly loaded to avoid
+ # detached-instance errors during later permission checks.
+ user_with_rels = load_user_with_relationships(
+ username=user.username,
+ )
+ if user_with_rels is None:
+ logger.warning(
+ "Failed to reload API key user %s with relationships; "
+ "using original user object which may have lazy-loaded "
+ "relationships",
+ user.username,
+ )
+ return user
+ return user_with_rels
+ raise PermissionError(
+ "Invalid or expired API key. "
+ "Create a new key at /api/v1/security/api_keys/."
+ )
+
# Fall back to configured username for development/single-user deployments
username = current_app.config.get("MCP_DEV_USERNAME")
@@ -209,11 +275,13 @@ def get_user_from_request() -> User:
f"JWT keys configured={jwt_configured})"
)
details.append("MCP_DEV_USERNAME is not configured")
+ configured_prefixes = current_app.config.get("FAB_API_KEY_PREFIXES", ["sst_"])
+ prefix_example = configured_prefixes[0] if configured_prefixes else "sst_"
raise ValueError(
"No authenticated user found. Tried:\n"
+ "\n".join(f" - {d}" for d in details)
- + "\n\nEither pass a valid JWT bearer token or configure "
- "MCP_DEV_USERNAME for development."
+ + f"\n\nEither pass a valid API key (Bearer {prefix_example}...), "
+ "JWT token, or configure MCP_DEV_USERNAME for development."
)
# Use helper function to load user with all required relationships
diff --git a/tests/unit_tests/mcp_service/test_auth_api_key.py b/tests/unit_tests/mcp_service/test_auth_api_key.py
new file mode 100644
index 00000000000..131bdb57f08
--- /dev/null
+++ b/tests/unit_tests/mcp_service/test_auth_api_key.py
@@ -0,0 +1,227 @@
+# 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 API key authentication in get_user_from_request()."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from flask import g
+
+from superset.mcp_service.auth import get_user_from_request
+
+
+@pytest.fixture
+def mock_user():
+ user = MagicMock()
+ user.username = "api_key_user"
+ return user
+
+
+@pytest.fixture
+def _enable_api_keys(app):
+ """Enable FAB API key auth and clear MCP_DEV_USERNAME so the API key
+ path is exercised instead of falling through to the dev-user fallback."""
+ app.config["FAB_API_KEY_ENABLED"] = True
+ old_dev = app.config.pop("MCP_DEV_USERNAME", None)
+ yield
+ app.config.pop("FAB_API_KEY_ENABLED", None)
+ if old_dev is not None:
+ app.config["MCP_DEV_USERNAME"] = old_dev
+
+
+@pytest.fixture
+def _disable_api_keys(app):
+ app.config["FAB_API_KEY_ENABLED"] = False
+ old_dev = app.config.pop("MCP_DEV_USERNAME", None)
+ yield
+ app.config.pop("FAB_API_KEY_ENABLED", None)
+ if old_dev is not None:
+ app.config["MCP_DEV_USERNAME"] = old_dev
+
+
+# -- Valid API key -> user loaded --
+
+
+@pytest.mark.usefixtures("_enable_api_keys")
+def test_valid_api_key_returns_user(app, mock_user) -> None:
+ """A valid API key should authenticate and return the user."""
+ mock_sm = MagicMock()
+ mock_sm._extract_api_key_from_request.return_value = "sst_abc123"
+ mock_sm.validate_api_key.return_value = mock_user
+
+ with app.test_request_context(headers={"Authorization": "Bearer sst_abc123"}):
+ g.user = None
+ app.appbuilder = MagicMock()
+ app.appbuilder.sm = mock_sm
+
+ with patch(
+ "superset.mcp_service.auth.load_user_with_relationships",
+ return_value=mock_user,
+ ):
+ result = get_user_from_request()
+
+ assert result.username == "api_key_user"
+ mock_sm.validate_api_key.assert_called_once_with("sst_abc123")
+
+
+# -- Invalid API key -> PermissionError --
+
+
+@pytest.mark.usefixtures("_enable_api_keys")
+def test_invalid_api_key_raises(app) -> None:
+ """An invalid API key should raise PermissionError."""
+ mock_sm = MagicMock()
+ mock_sm._extract_api_key_from_request.return_value = "sst_bad_key"
+ mock_sm.validate_api_key.return_value = None
+
+ with app.test_request_context(headers={"Authorization": "Bearer sst_bad_key"}):
+ g.user = None
+ app.appbuilder = MagicMock()
+ app.appbuilder.sm = mock_sm
+
+ with pytest.raises(PermissionError, match="Invalid or expired API key"):
+ get_user_from_request()
+
+
+# -- API key disabled -> falls through to next auth method --
+
+
+@pytest.mark.usefixtures("_disable_api_keys")
+def test_api_key_disabled_skips_auth(app) -> None:
+ """When FAB_API_KEY_ENABLED is False, API key auth is skipped entirely."""
+ mock_sm = MagicMock()
+
+ with app.test_request_context(headers={"Authorization": "Bearer sst_abc123"}):
+ g.user = None
+ app.appbuilder = MagicMock()
+ app.appbuilder.sm = mock_sm
+
+ # Without API key auth or MCP_DEV_USERNAME, should raise ValueError
+ # about no authenticated user (not about invalid API key)
+ with pytest.raises(ValueError, match="No authenticated user found"):
+ get_user_from_request()
+
+ # SecurityManager API key methods should never be called
+ mock_sm._extract_api_key_from_request.assert_not_called()
+
+
+# -- No request context -> API key auth skipped --
+
+
+@pytest.mark.usefixtures("_enable_api_keys")
+def test_no_request_context_skips_api_key_auth(app) -> None:
+ """Without a request context, API key auth should be skipped
+ (e.g., during MCP tool discovery with only an app context)."""
+ mock_sm = MagicMock()
+
+ with app.app_context():
+ g.user = None
+ app.appbuilder = MagicMock()
+ app.appbuilder.sm = mock_sm
+
+ # Explicitly mock has_request_context to False because the test
+ # framework's app fixture may implicitly provide a request context.
+ with patch("superset.mcp_service.auth.has_request_context", return_value=False):
+ with pytest.raises(ValueError, match="No authenticated user found"):
+ get_user_from_request()
+
+ mock_sm._extract_api_key_from_request.assert_not_called()
+
+
+# -- g.user already set -> API key auth skipped (JWT precedence) --
+
+
+@pytest.mark.usefixtures("_enable_api_keys")
+def test_existing_g_user_takes_precedence(app, mock_user) -> None:
+ """If g.user is already set (e.g., by JWT middleware), API key auth
+ should not be attempted."""
+ mock_sm = MagicMock()
+
+ with app.test_request_context(headers={"Authorization": "Bearer sst_abc123"}):
+ g.user = mock_user
+ app.appbuilder = MagicMock()
+ app.appbuilder.sm = mock_sm
+
+ result = get_user_from_request()
+
+ assert result.username == "api_key_user"
+ mock_sm._extract_api_key_from_request.assert_not_called()
+
+
+# -- FAB version without _extract_api_key_from_request --
+
+
+@pytest.mark.usefixtures("_enable_api_keys")
+def test_fab_without_extract_method_skips_gracefully(app) -> None:
+ """If FAB SecurityManager lacks _extract_api_key_from_request,
+ API key auth should be skipped with a debug log, not crash."""
+ mock_sm = MagicMock(spec=[]) # empty spec = no attributes
+
+ with app.test_request_context():
+ g.user = None
+ app.appbuilder = MagicMock()
+ app.appbuilder.sm = mock_sm
+
+ with pytest.raises(ValueError, match="No authenticated user found"):
+ get_user_from_request()
+
+
+# -- FAB version without validate_api_key --
+
+
+@pytest.mark.usefixtures("_enable_api_keys")
+def test_fab_without_validate_method_raises(app) -> None:
+ """If FAB has _extract_api_key_from_request but not validate_api_key,
+ should raise PermissionError about unavailable validation."""
+ mock_sm = MagicMock(spec=["_extract_api_key_from_request"])
+ mock_sm._extract_api_key_from_request.return_value = "sst_abc123"
+
+ with app.test_request_context(headers={"Authorization": "Bearer sst_abc123"}):
+ g.user = None
+ app.appbuilder = MagicMock()
+ app.appbuilder.sm = mock_sm
+
+ with pytest.raises(
+ PermissionError, match="API key validation is not available"
+ ):
+ get_user_from_request()
+
+
+# -- Relationship reload fallback --
+
+
+@pytest.mark.usefixtures("_enable_api_keys")
+def test_relationship_reload_failure_returns_original_user(app, mock_user) -> None:
+ """If load_user_with_relationships fails, the original user from
+ validate_api_key should be returned as fallback."""
+ mock_sm = MagicMock()
+ mock_sm._extract_api_key_from_request.return_value = "sst_abc123"
+ mock_sm.validate_api_key.return_value = mock_user
+
+ with app.test_request_context(headers={"Authorization": "Bearer sst_abc123"}):
+ g.user = None
+ app.appbuilder = MagicMock()
+ app.appbuilder.sm = mock_sm
+
+ with patch(
+ "superset.mcp_service.auth.load_user_with_relationships",
+ return_value=None,
+ ):
+ result = get_user_from_request()
+
+ assert result is mock_user
diff --git a/tests/unit_tests/security/api_test.py b/tests/unit_tests/security/api_test.py
index 869b3308478..9e0a25131c3 100644
--- a/tests/unit_tests/security/api_test.py
+++ b/tests/unit_tests/security/api_test.py
@@ -14,6 +14,8 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
+from typing import Any
+
import pytest
from superset.extensions import csrf
@@ -24,9 +26,10 @@ from superset.extensions import csrf
[{"WTF_CSRF_ENABLED": True}],
indirect=True,
)
-def test_csrf_not_exempt(app_context: None) -> None:
+def test_csrf_exempt_blueprints(app_context: None) -> None:
"""
- Test that REST API is not exempt from CSRF.
+ Test that only FAB security API blueprints (which use token-based auth)
+ are exempt from CSRF protection.
"""
assert {blueprint.name for blueprint in csrf._exempt_blueprints} == {
"GroupApi",
@@ -39,3 +42,21 @@ def test_csrf_not_exempt(app_context: None) -> None:
"PermissionApi",
"ViewMenuApi",
}
+
+
+@pytest.mark.parametrize(
+ "app",
+ [
+ {
+ "WTF_CSRF_ENABLED": True,
+ "FAB_API_KEY_ENABLED": True,
+ }
+ ],
+ indirect=True,
+)
+def test_csrf_exempt_blueprints_with_api_key(app: Any, app_context: None) -> None:
+ """
+ Test that ApiKeyApi blueprint is CSRF-exempt when FAB_API_KEY_ENABLED
+ config is enabled.
+ """
+ assert "ApiKeyApi" in {blueprint.name for blueprint in csrf._exempt_blueprints}