mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
feat(api-keys): add API key authentication via FAB SecurityManager (#37973)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Kamil Gabryjelski <kamil.gabryjelski@gmail.com>
This commit is contained in:
6
docs/static/feature-flags.json
vendored
6
docs/static/feature-flags.json
vendored
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
175
superset-frontend/src/features/apiKeys/ApiKeyCreateModal.tsx
Normal file
175
superset-frontend/src/features/apiKeys/ApiKeyCreateModal.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
236
superset-frontend/src/features/apiKeys/ApiKeyList.tsx
Normal file
236
superset-frontend/src/features/apiKeys/ApiKeyList.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
227
tests/unit_tests/mcp_service/test_auth_api_key.py
Normal file
227
tests/unit_tests/mcp_service/test_auth_api_key.py
Normal file
@@ -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
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user