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 && (