diff --git a/UPDATING.md b/UPDATING.md
index c8b78b1779e..27fc3428b9e 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -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.
diff --git a/docs/docs/security/granular-export-controls.mdx b/docs/docs/security/granular-export-controls.mdx
new file mode 100644
index 00000000000..806e21e02cf
--- /dev/null
+++ b/docs/docs/security/granular-export-controls.mdx
@@ -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` |
diff --git a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
index 64daa73cf5b..3885f195532 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
@@ -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: () =>
Error
,
}));
@@ -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,
diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
index d1858e730a8..d7c42fc2b2c 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
@@ -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) => {
logAction(LOG_ACTIONS_SQLLAB_DOWNLOAD_CSV, {});
@@ -396,19 +393,26 @@ const ResultSet = ({
onClick={createExploreResultsOnClick}
/>
)}
- {csv && canExportData && (
+ {csv && (
}
- 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 && (
- c.column_name),
- )}
- wrapped={false}
- copyNode={
- }
- 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, {})
- }
- />
- )}
+ c.column_name),
+ )
+ : ''
+ }
+ disabled={!canCopyClipboard}
+ wrapped={false}
+ copyNode={
+ }
+ 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, {})
+ }
+ />
>
);
diff --git a/superset-frontend/src/SqlLab/fixtures.ts b/superset-frontend/src/SqlLab/fixtures.ts
index 179a2a60295..4e0f5c5b83e 100644
--- a/superset-frontend/src/SqlLab/fixtures.ts
+++ b/superset-frontend/src/SqlLab/fixtures.ts
@@ -362,7 +362,7 @@ export const queryWithNoQueryLimit = {
},
{
is_dttm: false,
- name: 'gender',
+ column_name: 'gender',
type: 'STRING',
},
],
diff --git a/superset-frontend/src/components/CopyToClipboard/CopyToClipboard.test.tsx b/superset-frontend/src/components/CopyToClipboard/CopyToClipboard.test.tsx
index 4164cb45459..1ebb158dead 100644
--- a/superset-frontend/src/components/CopyToClipboard/CopyToClipboard.test.tsx
+++ b/superset-frontend/src/components/CopyToClipboard/CopyToClipboard.test.tsx
@@ -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( , { 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( , {
diff --git a/superset-frontend/src/components/CopyToClipboard/index.tsx b/superset-frontend/src/components/CopyToClipboard/index.tsx
index 3c3a51a845b..5df0f178f2a 100644
--- a/superset-frontend/src/components/CopyToClipboard/index.tsx
+++ b/superset-frontend/src/components/CopyToClipboard/index.tsx
@@ -43,6 +43,9 @@ class CopyToClip extends Component {
}
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 {
}
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 {
}
renderNotWrapped() {
- return this.renderTooltip('pointer');
+ return this.renderTooltip(this.props.disabled ? 'not-allowed' : 'pointer');
}
renderLink() {
@@ -114,7 +124,7 @@ class CopyToClip extends Component {
{this.props.text}
)}
- {this.renderTooltip('pointer')}
+ {this.renderTooltip(this.props.disabled ? 'not-allowed' : 'pointer')}
);
}
diff --git a/superset-frontend/src/components/CopyToClipboard/types.ts b/superset-frontend/src/components/CopyToClipboard/types.ts
index 10d87e8decf..f475e7d9825 100644
--- a/superset-frontend/src/components/CopyToClipboard/types.ts
+++ b/superset-frontend/src/components/CopyToClipboard/types.ts
@@ -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;
diff --git a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
index 7c648458024..121759846c8 100644
--- a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
+++ b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx
@@ -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>,
] => {
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({
diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx
index aa10717b64f..86efc2860e0 100644
--- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx
+++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/DownloadMenuItems.test.tsx
@@ -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> & {
+ canExportImage?: boolean;
+ },
+) => {
+ const downloadMenuItem = useDownloadMenuItems({
+ ...createProps(),
+ ...overrides,
+ });
+ const menuItems: MenuItem[] = [downloadMenuItem];
+ return ;
+};
+
+test('Screenshot menu items should be disabled when GranularExportControls is ON and canExportImage is false', () => {
+ mockIsFeatureEnabled.mockImplementation(
+ (flag: string) => flag === FeatureFlag.GranularExportControls,
+ );
+
+ render( , {
+ 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( , {
+ 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( , {
+ 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( , {
+ 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( , {
+ useRedux: true,
+ });
+
+ expect(screen.queryByTestId('tooltip-trigger')).not.toBeInTheDocument();
+
+ mockIsFeatureEnabled.mockReset();
+});
diff --git a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
index d30d552974d..f46b0596702 100644
--- a/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
+++ b/superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx
@@ -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 ? (
+
+ {text}
+
+
+ ) : (
+ 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),
},
];
diff --git a/superset-frontend/src/explore/actions/hydrateExplore.ts b/superset-frontend/src/explore/actions/hydrateExplore.ts
index cb51686b955..d488dfb4d29 100644
--- a/superset-frontend/src/explore/actions/hydrateExplore.ts
+++ b/superset-frontend/src/explore/actions/hydrateExplore.ts
@@ -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,
),
diff --git a/superset-frontend/src/explore/components/DataTableControl/CopyToClipboardButton.test.tsx b/superset-frontend/src/explore/components/DataTableControl/CopyToClipboardButton.test.tsx
index dafe14902e3..a66759833aa 100644
--- a/superset-frontend/src/explore/components/DataTableControl/CopyToClipboardButton.test.tsx
+++ b/superset-frontend/src/explore/components/DataTableControl/CopyToClipboardButton.test.tsx
@@ -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(
+ ,
+ {
+ 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;
+});
diff --git a/superset-frontend/src/explore/components/DataTableControl/index.tsx b/superset-frontend/src/explore/components/DataTableControl/index.tsx
index 823da872857..3a2698cd9b3 100644
--- a/superset-frontend/src/explore/components/DataTableControl/index.tsx
+++ b/superset-frontend/src/explore/components/DataTableControl/index.tsx
@@ -58,21 +58,30 @@ export const CopyButton = styled(Button)`
export const CopyToClipboardButton = ({
data,
columns,
+ disabled = false,
}: {
data?: TabularDataRow[];
columns?: string[];
+ disabled?: boolean;
}) => (
* {
line-height: 0;
}
diff --git a/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx b/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx
index 205e04938c3..a2f257af96c 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/components/DataTableControls.tsx
@@ -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 (
@@ -106,8 +109,18 @@ export const TableControls = ({
{(!onRowLimitChange || rowcount < (rowLimit ?? Infinity)) && (
)}
- {canDownload && (
+ {copyEnabled ? (
+ ) : (
+
+
+
+
+
)}
diff --git a/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx b/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx
index 22033ff5548..d60d588cb74 100644
--- a/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx
+++ b/superset-frontend/src/explore/components/DataTablesPane/test/DataTablesPane.test.tsx
@@ -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( , {
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( , {
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();
});
diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx
index c060c193c3c..9ad16fcf04b 100644
--- a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx
+++ b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx
@@ -766,6 +766,7 @@ describe('Additional actions tests', () => {
const props = createProps();
render( , {
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( , { useRedux: true });
+ render( , {
+ useRedux: true,
+ initialState: { explore: { can_export_image: true } },
+ });
userEvent.click(screen.getByLabelText('Menu actions trigger'));
userEvent.hover(await screen.findByText('Data Export Options'));
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
index 94eeec9a90b..f4dc6b57af8 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
@@ -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;
explore?: ExploreSlice & {
chartStates?: Record;
+ can_export_image?: boolean;
};
common?: {
conf?: {
@@ -231,6 +233,36 @@ export const useExploreAdditionalActionsMenu = (
: undefined;
},
);
+ const canExportImage = useSelector(
+ state => state.explore?.can_export_image ?? false,
+ );
+
+ const dataExportDisabled = !canDownloadCSV;
+ const imageExportDisabled = !canExportImage;
+
+ const dataExportLabel = (text: string) =>
+ dataExportDisabled ? (
+
+ {text}
+
+
+ ) : (
+ text
+ );
+
+ const imageExportLabel = (text: string) =>
+ imageExportDisabled ? (
+
+ {text}
+
+
+ ) : (
+ 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: ,
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: ,
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: ,
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: ,
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: ,
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: ,
+ 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: ,
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: ,
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: ,
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: ,
+ 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: ,
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
diff --git a/superset-frontend/src/hooks/usePermissions.ts b/superset-frontend/src/hooks/usePermissions.ts
index 0d961dc249e..cbaa70e5844 100644
--- a/superset-frontend/src/hooks/usePermissions.ts
+++ b/superset-frontend/src/hooks/usePermissions.ts
@@ -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,
diff --git a/superset-frontend/src/utils/copy.ts b/superset-frontend/src/utils/copy.ts
index dd2cb7efa20..91e4e726e27 100644
--- a/superset-frontend/src/utils/copy.ts
+++ b/superset-frontend/src/utils/copy.ts
@@ -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) => {
+ 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) =>
+ copyTextWithClipboardApi(getText).catch(() =>
+ getText().then(
+ text =>
+ new Promise((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;
diff --git a/superset/charts/api.py b/superset/charts/api.py
index f5dd6ce3cbd..4a8e48c29ef 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -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:
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 58ecb69428a..3cd8f0a30d7 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -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
diff --git a/superset/sqllab/api.py b/superset/sqllab/api.py
index f81031a601c..a2c669f0aa5 100644
--- a/superset/sqllab/api.py
+++ b/superset/sqllab/api.py
@@ -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")
diff --git a/tests/integration_tests/test_granular_export_api.py b/tests/integration_tests/test_granular_export_api.py
new file mode 100644
index 00000000000..18dd718dd64
--- /dev/null
+++ b/tests/integration_tests/test_granular_export_api.py
@@ -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