feat(security): add granular export controls - Phase 2 + 3 (#38581)

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
Co-authored-by: Beto Dealmeida <roberto@dealmeida.net>
Co-authored-by: Daniel Vaz Gaspar <danielvazgaspar@gmail.com>
This commit is contained in:
Hugh A. Miles II
2026-04-15 10:24:59 -04:00
committed by GitHub
parent 411f769896
commit b76080e291
24 changed files with 1092 additions and 92 deletions

View File

@@ -24,6 +24,20 @@ assists people when migrating to a new version.
## Next
### Granular Export Controls
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
| Permission | Controls |
|---|---|
| `can_export_data` | CSV, Excel, JSON exports |
| `can_export_image` | Screenshot/PDF exports |
| `can_copy_clipboard` | Copy-to-clipboard operations |
When the feature flag is enabled, these permissions are enforced on both the frontend (disabled buttons with tooltips) and backend (403 responses from API endpoints). When disabled, legacy `can_csv` behavior is preserved.
**Migration behavior:** All three new permissions are granted to every role that currently has `can_csv`, preserving existing access. Admins can then selectively revoke individual export permissions from specific roles as needed.
### Deck.gl MapBox viewport and opacity controls are functional
The Deck.gl MapBox chart's **Opacity**, **Default longitude**, **Default latitude**, and **Zoom** controls were previously non-functional — changing them had no effect on the rendered map. These controls are now wired up correctly.

View File

@@ -0,0 +1,78 @@
---
title: Granular Export Controls
sidebar_position: 4
---
# Granular Export Controls
Superset provides granular, permission-based controls for data export, image export, and clipboard operations. These replace the legacy `can_csv` permission with three fine-grained permissions that can be assigned independently to roles.
## Feature Flag
Granular export controls are gated behind the `GRANULAR_EXPORT_CONTROLS` feature flag. When the flag is disabled, the legacy `can_csv` permission behavior is preserved.
```python
FEATURE_FLAGS = {
"GRANULAR_EXPORT_CONTROLS": True,
}
```
## Permissions
| Permission | Resource | Controls |
| -------------------- | ---------- | ---------------------------------------------------------------------- |
| `can_export_data` | `Superset` | CSV, Excel, and JSON data exports from charts, dashboards, and SQL Lab |
| `can_export_image` | `Superset` | Screenshot (JPEG/PNG) and PDF exports from charts and dashboards |
| `can_copy_clipboard` | `Superset` | Copy-to-clipboard operations in SQL Lab and the Explore data pane |
## Default Role Assignments
The migration grants all three new permissions (`can_export_data`, `can_export_image`, `can_copy_clipboard`) to every role that currently has `can_csv`. This preserves existing behavior — no role loses access during the upgrade.
After the migration, admins can selectively revoke individual export permissions from any role to restrict access. For example, to prevent Gamma users from exporting data or images while still allowing clipboard operations, revoke `can_export_data` and `can_export_image` from the Gamma role.
## Configuration Steps
1. **Enable the feature flag** in `superset_config.py`:
```python
FEATURE_FLAGS = {
"GRANULAR_EXPORT_CONTROLS": True,
}
```
2. **Run the database migration** to register the new permissions:
```bash
superset db upgrade
```
3. **Initialize permissions** so roles are populated:
```bash
superset init
```
4. **Verify role assignments** in **Settings > List Roles**. Confirm that each role has the expected permissions from the table above.
5. **Customize as needed**: Grant or revoke individual export permissions on any role through the role editor.
## User Experience
When a user lacks a required export permission:
- **Menu items** (CSV, Excel, JSON, screenshot) appear **disabled** with an info tooltip icon explaining the restriction
- **Buttons** (SQL Lab download, clipboard copy) appear **disabled** with a tooltip on hover
- **API endpoints** return **403 Forbidden** when the corresponding permission is missing
## API Enforcement
The following API endpoints enforce granular export permissions when the feature flag is enabled:
| Endpoint | Required Permission |
| --------------------------------------------------------- | ------------------- |
| `GET /api/v1/chart/{id}/data/` (CSV/Excel format) | `can_export_data` |
| `GET /api/v1/chart/{id}/cache_screenshot/` | `can_export_image` |
| `POST /api/v1/dashboard/{id}/cache_dashboard_screenshot/` | `can_export_image` |
| `GET /api/v1/sqllab/export/{client_id}/` | `can_export_data` |
| `POST /api/v1/sqllab/export_streaming/` | `can_export_data` |

View File

@@ -41,12 +41,20 @@ import {
queryWithNoQueryLimit,
failedQueryWithFrontendTimeoutErrors,
} from 'src/SqlLab/fixtures';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { ViewLocations } from 'src/SqlLab/contributions';
import {
registerToolbarAction,
cleanupExtensions,
} from 'spec/helpers/extensionTestHelpers';
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn().mockReturnValue(false),
}));
const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
jest.mock('src/components/ErrorMessage', () => ({
ErrorMessageWithStackTrace: () => <div data-test="error-message">Error</div>,
}));
@@ -551,7 +559,9 @@ describe('ResultSet', () => {
},
}),
);
expect(queryByTestId('export-csv-button')).not.toBeInTheDocument();
const csvButton = queryByTestId('export-csv-button');
expect(csvButton).toBeInTheDocument();
expect(csvButton).toBeDisabled();
});
test('should allow copy to clipboard when user has permission to export data', async () => {
@@ -590,7 +600,9 @@ describe('ResultSet', () => {
},
}),
);
expect(queryByTestId('copy-to-clipboard-button')).not.toBeInTheDocument();
const clipboardButton = queryByTestId('copy-to-clipboard-button');
expect(clipboardButton).toBeInTheDocument();
expect(clipboardButton).toBeDisabled();
});
test('should include sqlEditorImmutableId in query object when fetching results', async () => {
@@ -760,6 +772,213 @@ describe('ResultSet', () => {
},
);
test('should show CSV button with granular can_export_data permission when flag is ON', async () => {
mockIsFeatureEnabled.mockImplementation(
(flag: FeatureFlag) => flag === FeatureFlag.GranularExportControls,
);
const { queryByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user: {
...user,
roles: {
sql_lab: [['can_export_data', 'Superset']],
},
},
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
const csvButton = queryByTestId('export-csv-button');
expect(csvButton).toBeInTheDocument();
expect(csvButton).toBeEnabled();
mockIsFeatureEnabled.mockReset();
});
test('should disable CSV button when granular flag is ON and user lacks can_export_data', async () => {
mockIsFeatureEnabled.mockImplementation(
(flag: FeatureFlag) => flag === FeatureFlag.GranularExportControls,
);
const { queryByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user: {
...user,
roles: {
sql_lab: [],
},
},
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
const csvButton = queryByTestId('export-csv-button');
expect(csvButton).toBeInTheDocument();
expect(csvButton).toBeDisabled();
mockIsFeatureEnabled.mockReset();
});
test('should show clipboard button with granular can_copy_clipboard permission when flag is ON', async () => {
mockIsFeatureEnabled.mockImplementation(
(flag: FeatureFlag) => flag === FeatureFlag.GranularExportControls,
);
const { queryByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user: {
...user,
roles: {
sql_lab: [['can_copy_clipboard', 'Superset']],
},
},
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
const clipboardButton = queryByTestId('copy-to-clipboard-button');
expect(clipboardButton).toBeInTheDocument();
expect(clipboardButton).toBeEnabled();
mockIsFeatureEnabled.mockReset();
});
test('should disable clipboard button when granular flag is ON and user lacks can_copy_clipboard', async () => {
mockIsFeatureEnabled.mockImplementation(
(flag: FeatureFlag) => flag === FeatureFlag.GranularExportControls,
);
const { queryByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user: {
...user,
roles: {
sql_lab: [['can_export_data', 'Superset']],
},
},
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
const clipboardButton = queryByTestId('copy-to-clipboard-button');
expect(clipboardButton).toBeInTheDocument();
expect(clipboardButton).toBeDisabled();
mockIsFeatureEnabled.mockReset();
});
test('should use legacy can_export_csv for both CSV and clipboard when granular flag is OFF', async () => {
mockIsFeatureEnabled.mockReturnValue(false);
const { queryByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user: {
...user,
roles: {
sql_lab: [['can_export_csv', 'SQLLab']],
},
},
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
const csvButton = queryByTestId('export-csv-button');
const clipboardButton = queryByTestId('copy-to-clipboard-button');
expect(csvButton).toBeInTheDocument();
expect(csvButton).toBeEnabled();
expect(clipboardButton).toBeInTheDocument();
expect(clipboardButton).toBeEnabled();
mockIsFeatureEnabled.mockReset();
});
test('disabled CSV button should show permission tooltip when granular flag is ON', async () => {
mockIsFeatureEnabled.mockImplementation(
(flag: FeatureFlag) => flag === FeatureFlag.GranularExportControls,
);
const { getByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user: {
...user,
roles: {
sql_lab: [['can_copy_clipboard', 'Superset']],
},
},
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
const csvButton = getByTestId('export-csv-button');
expect(csvButton).toBeDisabled();
fireEvent.mouseOver(csvButton.parentElement ?? csvButton);
await waitFor(() => {
expect(
screen.getByText("You don't have permission to export data"),
).toBeInTheDocument();
});
mockIsFeatureEnabled.mockReset();
});
test('disabled clipboard button should show permission tooltip when granular flag is ON', async () => {
mockIsFeatureEnabled.mockImplementation(
(flag: FeatureFlag) => flag === FeatureFlag.GranularExportControls,
);
const { getByTestId } = setup(
mockedProps,
mockStore({
...initialState,
user: {
...user,
roles: {
sql_lab: [['can_export_data', 'Superset']],
},
},
sqlLab: {
...initialState.sqlLab,
queries: {
[queries[0].id]: queries[0],
},
},
}),
);
const clipboardButton = getByTestId('copy-to-clipboard-button');
expect(clipboardButton).toBeDisabled();
fireEvent.mouseOver(clipboardButton.parentElement ?? clipboardButton);
await waitFor(() => {
expect(
screen.getByText("You don't have permission to copy to clipboard"),
).toBeInTheDocument();
});
mockIsFeatureEnabled.mockReset();
});
test('renders contributed toolbar action in results slot', async () => {
registerToolbarAction(
ViewLocations.sqllab.results,

View File

@@ -82,7 +82,7 @@ import {
LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV,
} from 'src/logger/LogUtils';
import { Icons } from '@superset-ui/core/components/Icons';
import { findPermission } from 'src/utils/findPermission';
import { usePermissions } from 'src/hooks/usePermissions';
import { StreamingExportModal } from 'src/components/StreamingExportModal';
import { useStreamingExport } from 'src/components/StreamingExportModal/useStreamingExport';
import { useConfirmModal } from 'src/hooks/useConfirmModal';
@@ -172,7 +172,6 @@ const ResultSet = ({
defaultQueryLimit,
useFixedHeight = false,
}: ResultSetProps) => {
const user = useSelector(({ user }: SqlLabRootState) => user, shallowEqual);
const streamingThreshold = useSelector(
(state: SqlLabRootState) =>
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD || 1000,
@@ -227,6 +226,10 @@ const ResultSet = ({
[query.results?.expanded_columns],
);
const {
canExportDataSqlLab: canExportData,
canCopyClipboardSqlLab: canCopyClipboard,
} = usePermissions();
const history = useHistory();
const dispatch = useDispatch();
const logAction = useLogAction({ queryId, sqlEditorId: query.sqlEditorId });
@@ -361,12 +364,6 @@ const ResultSet = ({
schema: query?.schema,
};
const canExportData = findPermission(
'can_export_csv',
'SQLLab',
user?.roles,
);
const handleDownloadCsv = (event: React.MouseEvent<HTMLElement>) => {
logAction(LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, {});
@@ -396,19 +393,26 @@ const ResultSet = ({
onClick={createExploreResultsOnClick}
/>
)}
{csv && canExportData && (
{csv && (
<Button
buttonSize="small"
variant="text"
color="primary"
icon={<Icons.DownloadOutlined iconSize="m" />}
tooltip={t('Download to CSV')}
tooltip={
!canExportData
? t("You don't have permission to export data")
: t('Download to CSV')
}
aria-label={t('Download to CSV')}
{...(!shouldUseStreamingExport() && {
href: getExportCsvUrl(query.id),
})}
disabled={!canExportData}
{...(canExportData &&
!shouldUseStreamingExport() && {
href: getExportCsvUrl(query.id),
})}
data-test="export-csv-button"
onClick={e => {
if (!canExportData) return;
const useStreaming = shouldUseStreamingExport();
if (useStreaming) {
@@ -427,30 +431,38 @@ const ResultSet = ({
}}
/>
)}
{canExportData && (
<CopyToClipboard
text={prepareCopyToClipboardTabularData(
data,
columns.map(c => c.column_name),
)}
wrapped={false}
copyNode={
<Button
buttonSize="small"
variant="text"
color="primary"
icon={<Icons.CopyOutlined iconSize="m" />}
tooltip={t('Copy to Clipboard')}
aria-label={t('Copy to Clipboard')}
data-test="copy-to-clipboard-button"
/>
}
hideTooltip
onCopyEnd={() =>
logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {})
}
/>
)}
<CopyToClipboard
text={
canCopyClipboard
? prepareCopyToClipboardTabularData(
data,
columns.map(c => c.column_name),
)
: ''
}
disabled={!canCopyClipboard}
wrapped={false}
copyNode={
<Button
buttonSize="small"
variant="text"
color="primary"
icon={<Icons.CopyOutlined iconSize="m" />}
tooltip={
!canCopyClipboard
? t("You don't have permission to copy to clipboard")
: t('Copy to Clipboard')
}
aria-label={t('Copy to Clipboard')}
disabled={!canCopyClipboard}
data-test="copy-to-clipboard-button"
/>
}
hideTooltip
onCopyEnd={() =>
logAction(LOG_ACTIONS_SQLLAB_COPY_RESULT_TO_CLIPBOARD, {})
}
/>
</>
);

View File

@@ -362,7 +362,7 @@ export const queryWithNoQueryLimit = {
},
{
is_dttm: false,
name: 'gender',
column_name: 'gender',
type: 'STRING',
},
],

View File

@@ -80,6 +80,30 @@ test('triggers onCopyEnd', async () => {
await waitFor(() => expect(onCopyEnd).toHaveBeenCalled());
});
test('does not copy when disabled', async () => {
const callback = jest.fn();
document.execCommand = callback;
const originalClipboard = { ...global.navigator.clipboard };
// @ts-expect-error
global.navigator.clipboard = { write: callback, writeText: callback };
render(<CopyToClipboard disabled text="Text" />, { useRedux: true });
const copyButton = screen.getByText('Copy');
expect(copyButton).toHaveAttribute('aria-disabled', 'true');
userEvent.click(copyButton);
await waitFor(() => {
expect(callback).not.toHaveBeenCalled();
});
jest.resetAllMocks();
// @ts-expect-error
global.navigator.clipboard = originalClipboard;
});
test('renders unwrapped', () => {
const text = 'Text';
render(<CopyToClipboard text={text} wrapped={false} />, {

View File

@@ -43,6 +43,9 @@ class CopyToClip extends Component<CopyToClipboardProps> {
}
onClick() {
if (this.props.disabled) {
return;
}
if (this.props.getText) {
this.props.getText((d: string) => {
this.copyToClipboard(Promise.resolve(d));
@@ -53,9 +56,16 @@ class CopyToClip extends Component<CopyToClipboardProps> {
}
getDecoratedCopyNode() {
return cloneElement(this.props.copyNode as ReactElement, {
style: { cursor: 'pointer' },
onClick: this.onClick,
const copyNode = this.props.copyNode as ReactElement;
const { disabled } = this.props;
return cloneElement(copyNode, {
style: {
...copyNode.props.style,
cursor: disabled ? 'not-allowed' : 'pointer',
},
onClick: disabled ? undefined : this.onClick,
'aria-disabled': disabled || undefined,
tabIndex: disabled ? -1 : copyNode.props.tabIndex,
});
}
@@ -98,7 +108,7 @@ class CopyToClip extends Component<CopyToClipboardProps> {
}
renderNotWrapped() {
return this.renderTooltip('pointer');
return this.renderTooltip(this.props.disabled ? 'not-allowed' : 'pointer');
}
renderLink() {
@@ -114,7 +124,7 @@ class CopyToClip extends Component<CopyToClipboardProps> {
{this.props.text}
</span>
)}
{this.renderTooltip('pointer')}
{this.renderTooltip(this.props.disabled ? 'not-allowed' : 'pointer')}
</span>
);
}

View File

@@ -20,6 +20,7 @@ import type { ReactNode } from 'react';
export interface CopyToClipboardProps {
copyNode?: ReactNode;
disabled?: boolean;
getText?: (callback: (data: string) => void) => void;
onCopyEnd?: () => void;
shouldShowText?: boolean;

View File

@@ -36,6 +36,7 @@ import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters';
import { getUrlParam } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
import { usePermissions } from 'src/hooks/usePermissions';
export const useHeaderActionsMenu = ({
customCss,
@@ -72,6 +73,7 @@ export const useHeaderActionsMenu = ({
Dispatch<SetStateAction<boolean>>,
] => {
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const { canExportImage } = usePermissions();
const history = useHistory();
const directPathToChild = useSelector(
(state: RootState) => state.dashboardState.directPathToChild,
@@ -169,6 +171,7 @@ export const useHeaderActionsMenu = ({
disabled: isLoading,
logEvent,
userCanExport,
canExportImage,
});
const reportMenuItem = useHeaderReportMenuItems({

View File

@@ -24,7 +24,11 @@ import {
waitFor,
} from 'spec/helpers/testing-library';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { SupersetClient } from '@superset-ui/core';
import {
FeatureFlag,
isFeatureEnabled,
SupersetClient,
} from '@superset-ui/core';
import { useDownloadMenuItems } from '.';
const mockAddSuccessToast = jest.fn();
@@ -41,6 +45,7 @@ jest.mock('src/components/MessageToasts/withToasts', () => ({
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn().mockReturnValue(false),
SupersetClient: {
get: jest.fn(),
},
@@ -136,3 +141,108 @@ test('Export as Example shows error toast on failure', async () => {
);
});
});
const mockIsFeatureEnabled = isFeatureEnabled as jest.Mock;
const MenuWrapperWithProps = (
overrides: Partial<ReturnType<typeof createProps>> & {
canExportImage?: boolean;
},
) => {
const downloadMenuItem = useDownloadMenuItems({
...createProps(),
...overrides,
});
const menuItems: MenuItem[] = [downloadMenuItem];
return <Menu forceSubMenuRender items={menuItems} />;
};
test('Screenshot menu items should be disabled when GranularExportControls is ON and canExportImage is false', () => {
mockIsFeatureEnabled.mockImplementation(
(flag: string) => flag === FeatureFlag.GranularExportControls,
);
render(<MenuWrapperWithProps canExportImage={false} />, {
useRedux: true,
});
const pdfItem = screen
.getByText('Export to PDF')
.closest('[role="menuitem"]');
const imageItem = screen
.getByText('Download as Image')
.closest('[role="menuitem"]');
expect(pdfItem).toHaveAttribute('aria-disabled', 'true');
expect(imageItem).toHaveAttribute('aria-disabled', 'true');
mockIsFeatureEnabled.mockReset();
});
test('Screenshot menu items should be enabled when GranularExportControls is ON and canExportImage is true', () => {
mockIsFeatureEnabled.mockImplementation(
(flag: string) => flag === FeatureFlag.GranularExportControls,
);
render(<MenuWrapperWithProps canExportImage />, {
useRedux: true,
});
const pdfItem = screen
.getByText('Export to PDF')
.closest('[role="menuitem"]');
const imageItem = screen
.getByText('Download as Image')
.closest('[role="menuitem"]');
expect(pdfItem).not.toHaveAttribute('aria-disabled', 'true');
expect(imageItem).not.toHaveAttribute('aria-disabled', 'true');
mockIsFeatureEnabled.mockReset();
});
test('Screenshot menu items should not be disabled when canExportImage is not provided', () => {
mockIsFeatureEnabled.mockReturnValue(false);
render(<MenuWrapperWithProps />, {
useRedux: true,
});
const pdfItem = screen
.getByText('Export to PDF')
.closest('[role="menuitem"]');
const imageItem = screen
.getByText('Download as Image')
.closest('[role="menuitem"]');
expect(pdfItem).not.toHaveAttribute('aria-disabled', 'true');
expect(imageItem).not.toHaveAttribute('aria-disabled', 'true');
mockIsFeatureEnabled.mockReset();
});
test('Disabled screenshot items should show tooltip icon when GranularExportControls is ON', () => {
mockIsFeatureEnabled.mockImplementation(
(flag: string) => flag === FeatureFlag.GranularExportControls,
);
render(<MenuWrapperWithProps canExportImage={false} />, {
useRedux: true,
});
const tooltipTriggers = screen.getAllByTestId('tooltip-trigger');
expect(tooltipTriggers.length).toBeGreaterThanOrEqual(2);
mockIsFeatureEnabled.mockReset();
});
test('Enabled screenshot items should not show tooltip icon', () => {
mockIsFeatureEnabled.mockImplementation(
(flag: string) => flag === FeatureFlag.GranularExportControls,
);
render(<MenuWrapperWithProps canExportImage />, {
useRedux: true,
});
expect(screen.queryByTestId('tooltip-trigger')).not.toBeInTheDocument();
mockIsFeatureEnabled.mockReset();
});

View File

@@ -37,6 +37,7 @@ import {
} from 'src/logger/LogUtils';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { MenuItemTooltip } from 'src/components/Chart/DisabledMenuItemTooltip';
import { DownloadScreenshotFormat } from './types';
export interface UseDownloadMenuItemsProps {
@@ -48,6 +49,7 @@ export interface UseDownloadMenuItemsProps {
title: string;
disabled?: boolean;
userCanExport?: boolean;
canExportImage?: boolean;
}
export const useDownloadMenuItems = (
@@ -62,6 +64,7 @@ export const useDownloadMenuItems = (
disabled,
title,
userCanExport,
canExportImage,
} = props;
const { addDangerToast, addSuccessToast } = useToasts();
@@ -150,28 +153,46 @@ export const useDownloadMenuItems = (
}
};
const imageDisabled = canExportImage === false;
const imageExportLabel = (text: string) =>
imageDisabled ? (
<span>
{text}
<MenuItemTooltip
title={t("You don't have permission to export images")}
/>
</span>
) : (
text
);
const screenshotMenuItems: MenuItem[] = isWebDriverScreenshotEnabled
? [
{
key: DownloadScreenshotFormat.PDF,
label: pdfMenuItemTitle,
label: imageExportLabel(pdfMenuItemTitle),
disabled: imageDisabled,
onClick: () => downloadScreenshot(DownloadScreenshotFormat.PDF),
},
{
key: DownloadScreenshotFormat.PNG,
label: imageMenuItemTitle,
label: imageExportLabel(imageMenuItemTitle),
disabled: imageDisabled,
onClick: () => downloadScreenshot(DownloadScreenshotFormat.PNG),
},
]
: [
{
key: 'download-pdf',
label: pdfMenuItemTitle,
label: imageExportLabel(pdfMenuItemTitle),
disabled: imageDisabled,
onClick: (e: any) => onDownloadPdf(e.domEvent),
},
{
key: 'download-image',
label: imageMenuItemTitle,
label: imageExportLabel(imageMenuItemTitle),
disabled: imageDisabled,
onClick: (e: any) => onDownloadImage(e.domEvent),
},
];

View File

@@ -161,10 +161,10 @@ export const hydrateExplore =
: findPermission('can_csv', 'Superset', user?.roles),
can_export_image: granularExport
? findPermission('can_export_image', 'Superset', user?.roles)
: true,
: findPermission('can_csv', 'Superset', user?.roles),
can_copy_clipboard: granularExport
? findPermission('can_copy_clipboard', 'Superset', user?.roles)
: true,
: findPermission('can_csv', 'Superset', user?.roles),
can_overwrite: ensureIsArray(slice?.owners).includes(
user?.userId as number,
),

View File

@@ -54,3 +54,31 @@ test('Should copy to clipboard', async () => {
// @ts-expect-error
global.navigator.clipboard = originalClipboard;
});
test('Should not copy to clipboard when disabled', async () => {
const callback = jest.fn();
document.execCommand = callback;
const originalClipboard = { ...global.navigator.clipboard };
// @ts-expect-error
global.navigator.clipboard = { write: callback, writeText: callback };
render(
<CopyToClipboardButton data={[{ copy: 'data', data: 'copy' }]} disabled />,
{
useRedux: true,
},
);
const copyButton = screen.getByRole('button');
expect(copyButton).toHaveAttribute('aria-disabled', 'true');
userEvent.click(copyButton);
await waitFor(() => {
expect(callback).not.toHaveBeenCalled();
});
jest.resetAllMocks();
// @ts-expect-error
global.navigator.clipboard = originalClipboard;
});

View File

@@ -58,21 +58,30 @@ export const CopyButton = styled(Button)`
export const CopyToClipboardButton = ({
data,
columns,
disabled = false,
}: {
data?: TabularDataRow[];
columns?: string[];
disabled?: boolean;
}) => (
<CopyToClipboard
text={
data && columns ? prepareCopyToClipboardTabularData(data, columns) : ''
!disabled && data && columns
? prepareCopyToClipboardTabularData(data, columns)
: ''
}
disabled={disabled}
wrapped={false}
copyNode={
<Icons.CopyOutlined
iconSize="l"
aria-label={t('Copy')}
aria-disabled={disabled}
role="button"
tabIndex={disabled ? -1 : 0}
css={css`
opacity: ${disabled ? 0.3 : 1};
cursor: ${disabled ? 'not-allowed' : 'pointer'};
&.anticon > * {
line-height: 0;
}

View File

@@ -18,8 +18,10 @@
*/
import { styled, css } from '@apache-superset/core/theme';
import { GenericDataType } from '@apache-superset/core/common';
import { t } from '@apache-superset/core/translation';
import { useMemo } from 'react';
import { zip } from 'lodash';
import { Tooltip } from '@superset-ui/core/components';
import { Select } from 'antd';
import {
CopyToClipboardButton,
@@ -28,6 +30,7 @@ import {
import { applyFormattingToTabularData } from 'src/utils/common';
import { getTimeColumns } from 'src/explore/components/DataTableControl/utils';
import RowCountLabel from 'src/components/RowCountLabel';
import { usePermissions } from 'src/hooks/usePermissions';
import { TableControlsProps } from '../types';
export const ROW_LIMIT_OPTIONS = [
@@ -60,7 +63,6 @@ export const TableControls = ({
columnTypes,
rowcount,
isLoading,
canDownload,
rowLimit,
rowLimitOptions,
onRowLimitChange,
@@ -82,6 +84,7 @@ export const TableControls = ({
() => applyFormattingToTabularData(data, formattedTimeColumns),
[data, formattedTimeColumns],
);
const { canCopyClipboard: copyEnabled } = usePermissions();
return (
<TableControlsWrapper>
<FilterInput onChangeHandler={onInputChange} shouldFocus />
@@ -106,8 +109,18 @@ export const TableControls = ({
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
<RowCountLabel rowcount={rowcount} loading={isLoading} />
)}
{canDownload && (
{copyEnabled ? (
<CopyToClipboardButton data={formattedData} columns={columnNames} />
) : (
<Tooltip title={t("You don't have permission to copy to clipboard")}>
<span>
<CopyToClipboardButton
data={formattedData}
columns={columnNames}
disabled
/>
</span>
</Tooltip>
)}
</div>
</TableControlsWrapper>

View File

@@ -104,23 +104,36 @@ describe('DataTablesPane', () => {
],
},
);
// @ts-expect-error
global.featureFlags = {
[FeatureFlag.GranularExportControls]: true,
};
const copyToClipboardSpy = jest.spyOn(copyUtils, 'default');
const props = createDataTablesPaneProps(456);
render(<DataTablesPane {...props} />, {
useRedux: true,
initialState: {
user: {
roles: {
gamma: [['can_copy_clipboard', 'Superset']],
},
},
},
});
userEvent.click(screen.getByText('Results'));
expect(await screen.findByText('1 row')).toBeVisible();
userEvent.click(screen.getByLabelText('Copy'));
await userEvent.click(screen.getByLabelText('Copy'));
expect(copyToClipboardSpy).toHaveBeenCalledTimes(1);
const value = await copyToClipboardSpy.mock.calls[0][0]();
expect(value).toBe('__timestamp\tgenre\n2009-01-01 00:00:00\tAction\n');
copyToClipboardSpy.mockRestore();
// @ts-expect-error
global.featureFlags = {};
fetchMock.clearHistory().removeRoutes();
});
test('Should not allow copy data table content when canDownload=false', async () => {
test('Should not allow copy data table content without clipboard permission', async () => {
fetchMock.post(
'glob:*/api/v1/chart/data?form_data=%7B%22slice_id%22%3A456%7D',
{
@@ -135,16 +148,38 @@ describe('DataTablesPane', () => {
],
},
);
// @ts-expect-error
global.featureFlags = {
[FeatureFlag.GranularExportControls]: true,
};
const copyToClipboardSpy = jest.spyOn(copyUtils, 'default');
const props = {
...createDataTablesPaneProps(456),
canDownload: false,
canDownload: true,
};
render(<DataTablesPane {...props} />, {
useRedux: true,
initialState: {
user: {
roles: {
gamma: [['can_export_data', 'Superset']],
},
},
},
});
userEvent.click(screen.getByText('Results'));
expect(await screen.findByText('1 row')).toBeVisible();
expect(screen.queryByLabelText('Copy')).not.toBeInTheDocument();
const copyButton = screen.getByLabelText('Copy');
expect(copyButton).toHaveAttribute('aria-disabled', 'true');
await userEvent.hover(copyButton);
expect(
await screen.findByText("You don't have permission to copy to clipboard"),
).toBeInTheDocument();
await userEvent.click(copyButton);
expect(copyToClipboardSpy).not.toHaveBeenCalled();
copyToClipboardSpy.mockRestore();
// @ts-expect-error
global.featureFlags = {};
fetchMock.clearHistory().removeRoutes();
});

View File

@@ -766,6 +766,7 @@ describe('Additional actions tests', () => {
const props = createProps();
render(<ExploreHeader {...props} />, {
useRedux: true,
initialState: { explore: { can_export_image: true } },
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
@@ -967,7 +968,10 @@ describe('Additional actions tests', () => {
const getSpy = mockExportCurrentViewBehavior();
render(<ExploreHeader {...props} />, { useRedux: true });
render(<ExploreHeader {...props} />, {
useRedux: true,
initialState: { explore: { can_export_image: true } },
});
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));

View File

@@ -52,6 +52,7 @@ import downloadAsImage from 'src/utils/downloadAsImage';
import { getChartPermalink } from 'src/utils/urlUtils';
import copyTextToClipboard from 'src/utils/copy';
import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown';
import { MenuItemTooltip } from 'src/components/Chart/DisabledMenuItemTooltip';
import { logEvent } from 'src/logger/actions';
import {
LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE,
@@ -174,6 +175,7 @@ interface ExploreState {
charts?: Record<number, ChartState>;
explore?: ExploreSlice & {
chartStates?: Record<number, JsonObject>;
can_export_image?: boolean;
};
common?: {
conf?: {
@@ -231,6 +233,36 @@ export const useExploreAdditionalActionsMenu = (
: undefined;
},
);
const canExportImage = useSelector<ExploreState, boolean>(
state => state.explore?.can_export_image ?? false,
);
const dataExportDisabled = !canDownloadCSV;
const imageExportDisabled = !canExportImage;
const dataExportLabel = (text: string) =>
dataExportDisabled ? (
<span>
{text}
<MenuItemTooltip
title={t("You don't have permission to export data")}
/>
</span>
) : (
text
);
const imageExportLabel = (text: string) =>
imageExportDisabled ? (
<span>
{text}
<MenuItemTooltip
title={t("You don't have permission to export images")}
/>
</span>
) : (
text
);
// Streaming export state and handlers
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
@@ -644,7 +676,7 @@ export const useExploreAdditionalActionsMenu = (
allDataChildren.push(
{
key: MENU_KEYS.EXPORT_TO_CSV,
label: t('Export to original .CSV'),
label: dataExportLabel(t('Export to original .CSV')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -660,7 +692,7 @@ export const useExploreAdditionalActionsMenu = (
},
{
key: MENU_KEYS.EXPORT_TO_CSV_PIVOTED,
label: t('Export to pivoted .CSV'),
label: dataExportLabel(t('Export to pivoted .CSV')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -676,7 +708,7 @@ export const useExploreAdditionalActionsMenu = (
},
{
key: MENU_KEYS.EXPORT_TO_PIVOT_XLSX,
label: t('Export to Pivoted Excel'),
label: dataExportLabel(t('Export to Pivoted Excel')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -698,7 +730,7 @@ export const useExploreAdditionalActionsMenu = (
} else {
allDataChildren.push({
key: MENU_KEYS.EXPORT_TO_CSV,
label: t('Export to .CSV'),
label: dataExportLabel(t('Export to .CSV')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -717,7 +749,7 @@ export const useExploreAdditionalActionsMenu = (
allDataChildren.push(
{
key: MENU_KEYS.EXPORT_TO_JSON,
label: t('Export to .JSON'),
label: dataExportLabel(t('Export to .JSON')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -733,8 +765,9 @@ export const useExploreAdditionalActionsMenu = (
},
{
key: MENU_KEYS.EXPORT_ALL_SCREENSHOT,
label: t('Export screenshot (jpeg)'),
label: imageExportLabel(t('Export screenshot (jpeg)')),
icon: <Icons.FileImageOutlined />,
disabled: imageExportDisabled,
onClick: (e: { domEvent: React.MouseEvent | React.KeyboardEvent }) => {
downloadAsImage(
'.panel-body .chart-container',
@@ -753,7 +786,7 @@ export const useExploreAdditionalActionsMenu = (
},
{
key: MENU_KEYS.EXPORT_TO_XLSX,
label: t('Export to Excel'),
label: dataExportLabel(t('Export to Excel')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -772,7 +805,7 @@ export const useExploreAdditionalActionsMenu = (
const currentViewChildren = [
{
key: MENU_KEYS.EXPORT_CURRENT_TO_CSV,
label: t('Export to .CSV'),
label: dataExportLabel(t('Export to .CSV')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -808,7 +841,7 @@ export const useExploreAdditionalActionsMenu = (
},
{
key: MENU_KEYS.EXPORT_CURRENT_TO_JSON,
label: t('Export to .JSON'),
label: dataExportLabel(t('Export to .JSON')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
@@ -837,8 +870,9 @@ export const useExploreAdditionalActionsMenu = (
},
{
key: MENU_KEYS.EXPORT_CURRENT_SCREENSHOT,
label: t('Export screenshot (jpeg)'),
label: imageExportLabel(t('Export screenshot (jpeg)')),
icon: <Icons.FileImageOutlined />,
disabled: imageExportDisabled,
onClick: (e: { domEvent: React.MouseEvent | React.KeyboardEvent }) => {
downloadAsImage(
'.panel-body .chart-container',
@@ -857,7 +891,7 @@ export const useExploreAdditionalActionsMenu = (
},
{
key: MENU_KEYS.EXPORT_CURRENT_XLSX,
label: t('Export to Excel'),
label: dataExportLabel(t('Export to Excel')),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: async () => {
@@ -1037,6 +1071,7 @@ export const useExploreAdditionalActionsMenu = (
theme.sizeUnit,
ownState,
hasExportCurrentView,
canExportImage,
]);
// Return streaming modal state and handlers for parent to render

View File

@@ -34,18 +34,32 @@ export const usePermissions = () => {
const canCsvLegacy = useSelector((state: RootState) =>
findPermission('can_csv', 'Superset', state.user?.roles),
);
const canExportData = useSelector((state: RootState) =>
const canExportCsvSqlLab = useSelector((state: RootState) =>
findPermission('can_export_csv', 'SQLLab', state.user?.roles),
);
const canExportDataGranular = useSelector((state: RootState) =>
findPermission('can_export_data', 'Superset', state.user?.roles),
);
const canExportImage = useSelector((state: RootState) =>
const canExportImageGranular = useSelector((state: RootState) =>
findPermission('can_export_image', 'Superset', state.user?.roles),
);
const canCopyClipboard = useSelector((state: RootState) =>
const canCopyClipboardGranular = useSelector((state: RootState) =>
findPermission('can_copy_clipboard', 'Superset', state.user?.roles),
);
const canDownload = isFeatureEnabled(FeatureFlag.GranularExportControls)
? canExportData
const granularExport = isFeatureEnabled(FeatureFlag.GranularExportControls);
const canExportData = granularExport ? canExportDataGranular : canCsvLegacy;
const canExportImage = granularExport ? canExportImageGranular : canCsvLegacy;
const canCopyClipboard = granularExport
? canCopyClipboardGranular
: canCsvLegacy;
const canDownload = canExportData;
// SQL Lab uses a separate legacy permission (can_export_csv on SQLLab)
const canExportDataSqlLab = granularExport
? canExportDataGranular
: canExportCsvSqlLab;
const canCopyClipboardSqlLab = granularExport
? canCopyClipboardGranular
: canExportCsvSqlLab;
const canDrill = useSelector((state: RootState) =>
findPermission('can_drill', 'Dashboard', state.user?.roles),
);
@@ -69,8 +83,10 @@ export const usePermissions = () => {
canDatasourceSamples,
canDownload,
canExportData,
canExportDataSqlLab,
canExportImage,
canCopyClipboard,
canCopyClipboardSqlLab,
canDrill,
canDrillBy,
canDrillToDetail,

View File

@@ -17,6 +17,67 @@
* under the License.
*/
import copyTextToClipboard from '@superset-ui/core/utils/copy';
const isSafari = (): boolean => {
const { userAgent } = navigator;
return Boolean(userAgent && /^((?!chrome|android).)*safari/i.test(userAgent));
};
const copyTextWithClipboardApi = async (getText: () => Promise<string>) => {
if (isSafari()) {
try {
const clipboardItem = new ClipboardItem({
'text/plain': getText(),
});
await navigator.clipboard.write([clipboardItem]);
} catch {
const text = await getText();
await navigator.clipboard.writeText(text);
}
} else {
const text = await getText();
await navigator.clipboard.writeText(text);
}
};
const copyTextToClipboard = (getText: () => Promise<string>) =>
copyTextWithClipboardApi(getText).catch(() =>
getText().then(
text =>
new Promise<void>((resolve, reject) => {
const selection: Selection | null = document.getSelection();
if (selection) {
selection.removeAllRanges();
const range = document.createRange();
const span = document.createElement('span');
span.textContent = text;
span.style.position = 'fixed';
span.style.top = '0';
span.style.clip = 'rect(0, 0, 0, 0)';
span.style.whiteSpace = 'pre';
document.body.appendChild(span);
range.selectNode(span);
selection.addRange(range);
try {
if (!document.execCommand('copy')) {
reject();
}
} catch (err) {
reject();
}
document.body.removeChild(span);
if (selection.removeRange) {
selection.removeRange(range);
} else {
selection.removeAllRanges();
}
}
resolve();
}),
),
);
export default copyTextToClipboard;

View File

@@ -81,7 +81,7 @@ from superset.commands.importers.v1.utils import get_contents_from_bundle
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
from superset.daos.chart import ChartDAO
from superset.exceptions import ScreenshotImageNotAvailableException
from superset.extensions import event_logger
from superset.extensions import event_logger, security_manager
from superset.models.slice import Slice
from superset.tasks.thumbnails import cache_chart_thumbnail
from superset.tasks.utils import get_current_user
@@ -574,8 +574,9 @@ class ChartRestApi(BaseSupersetModelRestApi):
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
f".cache_screenshot",
action=lambda self, *args, **kwargs: (
f"{self.__class__.__name__}.cache_screenshot"
),
log_to_statsd=False,
)
def cache_screenshot(self, pk: int, **kwargs: Any) -> WerkzeugResponse:
@@ -616,6 +617,10 @@ class ChartRestApi(BaseSupersetModelRestApi):
500:
$ref: '#/components/responses/500'
"""
if is_feature_enabled(
"GRANULAR_EXPORT_CONTROLS"
) and not security_manager.can_access("can_export_image", "Superset"):
return self.response_403()
rison_dict = kwargs["rison"]
force = rison_dict.get("force")
window_size = rison_dict.get("window_size") or DEFAULT_CHART_WINDOW_SIZE
@@ -699,6 +704,10 @@ class ChartRestApi(BaseSupersetModelRestApi):
500:
$ref: '#/components/responses/500'
"""
if is_feature_enabled(
"GRANULAR_EXPORT_CONTROLS"
) and not security_manager.can_access("can_export_image", "Superset"):
return self.response_403()
chart = self.datamodel.get(pk, self._base_filters)
if not chart:
@@ -761,6 +770,10 @@ class ChartRestApi(BaseSupersetModelRestApi):
500:
$ref: '#/components/responses/500'
"""
if is_feature_enabled(
"GRANULAR_EXPORT_CONTROLS"
) and not security_manager.can_access("can_export_image", "Superset"):
return self.response_403()
chart = cast(Slice, self.datamodel.get(pk, self._base_filters))
if not chart:
return self.response_404()
@@ -876,8 +889,9 @@ class ChartRestApi(BaseSupersetModelRestApi):
@rison(get_fav_star_ids_schema)
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
f".favorite_status",
action=lambda self, *args, **kwargs: (
f"{self.__class__.__name__}.favorite_status"
),
log_to_statsd=False,
)
def favorite_status(self, **kwargs: Any) -> Response:
@@ -968,8 +982,9 @@ class ChartRestApi(BaseSupersetModelRestApi):
@safe
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
f".remove_favorite",
action=lambda self, *args, **kwargs: (
f"{self.__class__.__name__}.remove_favorite"
),
log_to_statsd=False,
)
def remove_favorite(self, pk: int) -> Response:

View File

@@ -39,7 +39,7 @@ from marshmallow import ValidationError
from werkzeug.wrappers import Response as WerkzeugResponse
from werkzeug.wsgi import FileWrapper
from superset import db
from superset import db, is_feature_enabled
from superset.charts.schemas import ChartEntityResponseSchema
from superset.commands.dashboard.copy import CopyDashboardCommand
from superset.commands.dashboard.create import CreateDashboardCommand
@@ -114,7 +114,7 @@ from superset.dashboards.schemas import (
thumbnail_query_schema,
)
from superset.exceptions import ScreenshotImageNotAvailableException
from superset.extensions import event_logger
from superset.extensions import event_logger, security_manager
from superset.models.dashboard import Dashboard
from superset.models.embedded_dashboard import EmbeddedDashboard
from superset.security.guest_token import GuestUser
@@ -1389,6 +1389,10 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
500:
$ref: '#/components/responses/500'
"""
if is_feature_enabled(
"GRANULAR_EXPORT_CONTROLS"
) and not security_manager.can_access("can_export_image", "Superset"):
return self.response_403()
try:
payload = CacheScreenshotSchema().load(request.json)
except ValidationError as error:
@@ -1505,6 +1509,10 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
500:
$ref: '#/components/responses/500'
"""
if is_feature_enabled(
"GRANULAR_EXPORT_CONTROLS"
) and not security_manager.can_access("can_export_image", "Superset"):
return self.response_403()
dashboard = self.datamodel.get(pk, self._base_filters)
# Making sure the dashboard still exists

View File

@@ -37,7 +37,7 @@ from superset.commands.sql_lab.streaming_export_command import (
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
from superset.daos.database import DatabaseDAO
from superset.daos.query import QueryDAO
from superset.extensions import event_logger
from superset.extensions import event_logger, security_manager
from superset.jinja_context import get_template_processor
from superset.models.sql_lab import Query
from superset.sql.parse import SQLScript
@@ -149,8 +149,9 @@ class SqlLabRestApi(BaseSupersetApi):
@statsd_metrics
@requires_json
@event_logger.log_this_with_context(
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
f".estimate_query_cost",
action=lambda self, *args, **kwargs: (
f"{self.__class__.__name__}.estimate_query_cost"
),
log_to_statsd=False,
)
def estimate_query_cost(self) -> Response:
@@ -302,6 +303,10 @@ class SqlLabRestApi(BaseSupersetApi):
500:
$ref: '#/components/responses/500'
"""
if is_feature_enabled(
"GRANULAR_EXPORT_CONTROLS"
) and not security_manager.can_access("can_export_data", "Superset"):
return self.response_403()
result = SqlResultExportCommand(client_id=client_id).run()
query, data, row_count = result["query"], result["data"], result["count"]
@@ -331,9 +336,9 @@ class SqlLabRestApi(BaseSupersetApi):
@permission_name("read")
@statsd_metrics
@event_logger.log_this_with_context(
action=lambda self,
*args,
**kwargs: f"{self.__class__.__name__}.export_streaming_csv",
action=lambda self, *args, **kwargs: (
f"{self.__class__.__name__}.export_streaming_csv"
),
log_to_statsd=False,
)
def export_streaming_csv(self) -> Response:
@@ -376,6 +381,10 @@ class SqlLabRestApi(BaseSupersetApi):
500:
$ref: '#/components/responses/500'
"""
if is_feature_enabled(
"GRANULAR_EXPORT_CONTROLS"
) and not security_manager.can_access("can_export_data", "Superset"):
return self.response_403()
# Extract parameters from form data
client_id = request.form.get("client_id")
filename = request.form.get("filename")

View File

@@ -0,0 +1,275 @@
# 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.
"""Integration tests for Phase 2 granular export controls.
These tests verify that the GRANULAR_EXPORT_CONTROLS feature flag gates
access to chart/dashboard screenshot endpoints (can_export_image) and
SQL Lab export endpoints (can_export_data).
"""
from unittest.mock import patch
import prison
import pytest
from superset.security import SupersetSecurityManager
from tests.integration_tests.base_tests import SupersetTestCase
from tests.integration_tests.conftest import with_feature_flags
from tests.integration_tests.constants import ADMIN_USERNAME
from tests.integration_tests.fixtures.birth_names_dashboard import (
load_birth_names_dashboard_with_slices, # noqa: F401
load_birth_names_data, # noqa: F401
)
def _deny_can_export_image(perm: str, view: str) -> bool:
"""Return False only for can_export_image on Superset, allow everything else."""
return perm != "can_export_image" or view != "Superset"
def _deny_can_export_data(perm: str, view: str) -> bool:
"""Return False only for can_export_data on Superset, allow everything else."""
return perm != "can_export_data" or view != "Superset"
class TestGranularExportChartAPI(SupersetTestCase):
"""Test granular export controls on chart screenshot endpoints."""
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@with_feature_flags(GRANULAR_EXPORT_CONTROLS=True, THUMBNAILS=True)
@patch.object(
SupersetSecurityManager,
"can_access",
side_effect=_deny_can_export_image,
)
def test_chart_cache_screenshot_403_without_can_export_image(
self, mock_can_access
) -> None:
"""When GRANULAR_EXPORT_CONTROLS is ON and user lacks can_export_image,
cache_screenshot should return 403."""
self.login(ADMIN_USERNAME)
chart = self.get_slice("Girls")
uri = f"api/v1/chart/{chart.id}/cache_screenshot/"
rison_params = prison.dumps({"force": False})
rv = self.client.get(f"{uri}?q={rison_params}")
assert rv.status_code == 403
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@with_feature_flags(GRANULAR_EXPORT_CONTROLS=True, THUMBNAILS=True)
@patch.object(
SupersetSecurityManager,
"can_access",
side_effect=_deny_can_export_image,
)
def test_chart_screenshot_403_without_can_export_image(
self, mock_can_access
) -> None:
"""When GRANULAR_EXPORT_CONTROLS is ON and user lacks can_export_image,
screenshot should return 403."""
self.login(ADMIN_USERNAME)
chart = self.get_slice("Girls")
uri = f"api/v1/chart/{chart.id}/screenshot/fake_digest/"
rv = self.client.get(uri)
assert rv.status_code == 403
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@with_feature_flags(GRANULAR_EXPORT_CONTROLS=True, THUMBNAILS=True)
@patch.object(
SupersetSecurityManager,
"can_access",
side_effect=_deny_can_export_image,
)
def test_chart_thumbnail_403_without_can_export_image(
self, mock_can_access
) -> None:
"""When GRANULAR_EXPORT_CONTROLS is ON and user lacks can_export_image,
thumbnail should return 403."""
self.login(ADMIN_USERNAME)
chart = self.get_slice("Girls")
uri = f"api/v1/chart/{chart.id}/thumbnail/{chart.digest}/"
rv = self.client.get(uri)
assert rv.status_code == 403
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@with_feature_flags(GRANULAR_EXPORT_CONTROLS=False)
def test_chart_cache_screenshot_allowed_when_flag_disabled(self) -> None:
"""When GRANULAR_EXPORT_CONTROLS is OFF, no permission check occurs
and the request proceeds (may return 404/422 due to missing thumbnails
config, but NOT 403)."""
self.login(ADMIN_USERNAME)
chart = self.get_slice("Girls")
uri = f"api/v1/chart/{chart.id}/cache_screenshot/"
rison_params = prison.dumps({"force": False})
rv = self.client.get(f"{uri}?q={rison_params}")
assert rv.status_code != 403
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@with_feature_flags(GRANULAR_EXPORT_CONTROLS=False)
def test_chart_screenshot_allowed_when_flag_disabled(self) -> None:
"""When GRANULAR_EXPORT_CONTROLS is OFF, screenshot endpoint does
not enforce granular permission."""
self.login(ADMIN_USERNAME)
chart = self.get_slice("Girls")
uri = f"api/v1/chart/{chart.id}/screenshot/fake_digest/"
rv = self.client.get(uri)
assert rv.status_code != 403
class TestGranularExportDashboardAPI(SupersetTestCase):
"""Test granular export controls on dashboard screenshot endpoints."""
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@with_feature_flags(
GRANULAR_EXPORT_CONTROLS=True,
THUMBNAILS=True,
ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS=True,
)
@patch.object(
SupersetSecurityManager,
"can_access",
side_effect=_deny_can_export_image,
)
def test_dashboard_cache_screenshot_403_without_can_export_image(
self, mock_can_access
) -> None:
"""When GRANULAR_EXPORT_CONTROLS is ON and user lacks can_export_image,
cache_dashboard_screenshot should return 403."""
self.login(ADMIN_USERNAME)
dashboard = self.get_dash_by_slug("births") or self.get_dash_by_slug(
"birth_names"
)
uri = f"api/v1/dashboard/{dashboard.id}/cache_dashboard_screenshot/"
rison_params = prison.dumps({"force": False})
rv = self.client.post(
f"{uri}?q={rison_params}",
json={},
)
assert rv.status_code == 403
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@with_feature_flags(
GRANULAR_EXPORT_CONTROLS=True,
THUMBNAILS=True,
ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS=True,
)
@patch.object(
SupersetSecurityManager,
"can_access",
side_effect=_deny_can_export_image,
)
def test_dashboard_screenshot_403_without_can_export_image(
self, mock_can_access
) -> None:
"""When GRANULAR_EXPORT_CONTROLS is ON and user lacks can_export_image,
screenshot should return 403."""
self.login(ADMIN_USERNAME)
dashboard = self.get_dash_by_slug("births") or self.get_dash_by_slug(
"birth_names"
)
uri = f"api/v1/dashboard/{dashboard.id}/screenshot/fake_digest/"
rv = self.client.get(uri)
assert rv.status_code == 403
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
@with_feature_flags(
GRANULAR_EXPORT_CONTROLS=False,
THUMBNAILS=True,
ENABLE_DASHBOARD_SCREENSHOT_ENDPOINTS=True,
)
@patch.object(
SupersetSecurityManager,
"can_access",
side_effect=_deny_can_export_image,
)
def test_dashboard_cache_screenshot_allowed_when_flag_disabled(
self, mock_can_access
) -> None:
"""When GRANULAR_EXPORT_CONTROLS is OFF, the granular permission check
is skipped even if the user lacks can_export_image."""
self.login(ADMIN_USERNAME)
dashboard = self.get_dash_by_slug("births") or self.get_dash_by_slug(
"birth_names"
)
uri = f"api/v1/dashboard/{dashboard.id}/cache_dashboard_screenshot/"
rison_params = prison.dumps({"force": False})
rv = self.client.post(f"{uri}?q={rison_params}", json={})
assert rv.status_code == 202
class TestGranularExportSqlLabAPI(SupersetTestCase):
"""Test granular export controls on SQL Lab export endpoints."""
@with_feature_flags(GRANULAR_EXPORT_CONTROLS=True)
@patch.object(
SupersetSecurityManager,
"can_access",
side_effect=_deny_can_export_data,
)
def test_export_csv_403_without_can_export_data(self, mock_can_access) -> None:
"""When GRANULAR_EXPORT_CONTROLS is ON and user lacks can_export_data,
export_csv should return 403."""
self.login(ADMIN_USERNAME)
uri = "api/v1/sqllab/export/fake_client_id/"
rv = self.client.get(uri)
assert rv.status_code == 403
@with_feature_flags(GRANULAR_EXPORT_CONTROLS=True)
@patch.object(
SupersetSecurityManager,
"can_access",
side_effect=_deny_can_export_data,
)
def test_export_streaming_csv_403_without_can_export_data(
self, mock_can_access
) -> None:
"""When GRANULAR_EXPORT_CONTROLS is ON and user lacks can_export_data,
export_streaming_csv should return 403."""
self.login(ADMIN_USERNAME)
uri = "api/v1/sqllab/export_streaming/"
rv = self.client.post(uri, data={"client_id": "fake_client_id"})
assert rv.status_code == 403
@with_feature_flags(GRANULAR_EXPORT_CONTROLS=False)
@patch.object(
SupersetSecurityManager,
"can_access",
side_effect=_deny_can_export_data,
)
def test_export_csv_allowed_when_flag_disabled(self, mock_can_access) -> None:
"""When GRANULAR_EXPORT_CONTROLS is OFF, no granular permission check
is enforced. The request may fail for other reasons (no query found),
but must not return 403."""
self.login(ADMIN_USERNAME)
uri = "api/v1/sqllab/export/fake_client_id/"
rv = self.client.get(uri)
assert rv.status_code != 403
@with_feature_flags(GRANULAR_EXPORT_CONTROLS=False)
@patch.object(
SupersetSecurityManager,
"can_access",
side_effect=_deny_can_export_data,
)
def test_export_streaming_csv_allowed_when_flag_disabled(
self, mock_can_access
) -> None:
"""When GRANULAR_EXPORT_CONTROLS is OFF, the granular permission check
is skipped even if the user lacks can_export_data."""
self.login(ADMIN_USERNAME)
uri = "api/v1/sqllab/export_streaming/"
rv = self.client.post(uri, data={"client_id": "fake_client_id"})
assert rv.status_code != 403