diff --git a/docs/static/feature-flags.json b/docs/static/feature-flags.json index 227d529c1db..5d7a86994f1 100644 --- a/docs/static/feature-flags.json +++ b/docs/static/feature-flags.json @@ -51,6 +51,12 @@ "lifecycle": "development", "description": "Enable Superset extensions for custom functionality without modifying core" }, + { + "name": "GRANULAR_EXPORT_CONTROLS", + "default": false, + "lifecycle": "development", + "description": "Enable granular export controls (can_export_data, can_export_image, can_copy_clipboard) instead of the single can_csv permission" + }, { "name": "MATRIXIFY", "default": false, diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index 7a5ba44dce8..18bc85af00c 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -56,6 +56,7 @@ export enum FeatureFlag { FilterBarClosedByDefault = 'FILTERBAR_CLOSED_BY_DEFAULT', GlobalAsyncQueries = 'GLOBAL_ASYNC_QUERIES', GlobalTaskFramework = 'GLOBAL_TASK_FRAMEWORK', + GranularExportControls = 'GRANULAR_EXPORT_CONTROLS', ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW', Matrixify = 'MATRIXIFY', ScheduledQueries = 'SCHEDULED_QUERIES', diff --git a/superset-frontend/src/explore/actions/hydrateExplore.ts b/superset-frontend/src/explore/actions/hydrateExplore.ts index b8e0375a648..0ceb1259b7b 100644 --- a/superset-frontend/src/explore/actions/hydrateExplore.ts +++ b/superset-frontend/src/explore/actions/hydrateExplore.ts @@ -29,9 +29,11 @@ import { Dispatch } from 'redux'; import { Currency, ensureIsArray, + FeatureFlag, getCategoricalSchemeRegistry, getColumnLabel, getSequentialSchemeRegistry, + isFeatureEnabled, NO_TIME_RANGE, QueryFormColumn, VizType, @@ -142,11 +144,20 @@ export const hydrateExplore = if (colorSchemeKey) verifyColorScheme(ColorSchemeType.CATEGORICAL); if (linearColorSchemeKey) verifyColorScheme(ColorSchemeType.SEQUENTIAL); + const granularExport = isFeatureEnabled(FeatureFlag.GranularExportControls); const exploreState = { // note this will add `form_data` to state, // which will be manipulable by future reducers. can_add: findPermission('can_write', 'Chart', user?.roles), - can_download: findPermission('can_csv', 'Superset', user?.roles), + can_download: granularExport + ? findPermission('can_export_data', 'Superset', user?.roles) + : findPermission('can_csv', 'Superset', user?.roles), + can_export_image: granularExport + ? findPermission('can_export_image', 'Superset', user?.roles) + : true, + can_copy_clipboard: granularExport + ? findPermission('can_copy_clipboard', 'Superset', user?.roles) + : true, can_overwrite: ensureIsArray(slice?.owners).includes( user?.userId as number, ), diff --git a/superset-frontend/src/explore/reducers/exploreReducer.ts b/superset-frontend/src/explore/reducers/exploreReducer.ts index d038dd40003..e940699e32c 100644 --- a/superset-frontend/src/explore/reducers/exploreReducer.ts +++ b/superset-frontend/src/explore/reducers/exploreReducer.ts @@ -41,6 +41,8 @@ import { SaveActionType } from 'src/explore/types'; export interface ExploreState { can_add?: boolean; can_download?: boolean; + can_export_image?: boolean; + can_copy_clipboard?: boolean; can_overwrite?: boolean; isDatasourceMetaLoading?: boolean; isDatasourcesLoading?: boolean; diff --git a/superset-frontend/src/explore/types.ts b/superset-frontend/src/explore/types.ts index 6bc6b1bb303..a032de3dd76 100644 --- a/superset-frontend/src/explore/types.ts +++ b/superset-frontend/src/explore/types.ts @@ -112,6 +112,8 @@ export interface ExplorePageState { explore: { can_add: boolean; can_download: boolean; + can_export_image: boolean; + can_copy_clipboard: boolean; can_overwrite: boolean; isDatasourceMetaLoading: boolean; isStarred: boolean; diff --git a/superset-frontend/src/hooks/usePermissions.test.tsx b/superset-frontend/src/hooks/usePermissions.test.tsx new file mode 100644 index 00000000000..23d704c8e40 --- /dev/null +++ b/superset-frontend/src/hooks/usePermissions.test.tsx @@ -0,0 +1,128 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { renderHook } from '@testing-library/react-hooks'; +import { Provider } from 'react-redux'; +import { ReactNode } from 'react'; +import configureStore from 'redux-mock-store'; +import { usePermissions } from './usePermissions'; + +const mockStore = configureStore([]); + +const rolesWithAllPerms = { + Admin: [ + ['can_csv', 'Superset'], + ['can_export_data', 'Superset'], + ['can_export_image', 'Superset'], + ['can_copy_clipboard', 'Superset'], + ['can_explore', 'Superset'], + ], +}; + +const rolesWithoutExportPerms = { + Gamma: [ + ['can_explore', 'Superset'], + ['can_copy_clipboard', 'Superset'], + ], +}; + +const rolesWithLegacyCsvOnly = { + CustomRole: [ + ['can_csv', 'Superset'], + ['can_explore', 'Superset'], + ], +}; + +function createWrapper(roles: Record) { + const store = mockStore({ user: { roles } }); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +jest.mock('@superset-ui/core', () => ({ + ...jest.requireActual('@superset-ui/core'), + isFeatureEnabled: jest.fn(), +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { isFeatureEnabled } = require('@superset-ui/core'); + +test('returns canExportData true when user has can_export_data', () => { + isFeatureEnabled.mockReturnValue(true); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithAllPerms), + }); + expect(result.current.canExportData).toBe(true); +}); + +test('returns canExportImage true when user has can_export_image', () => { + isFeatureEnabled.mockReturnValue(true); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithAllPerms), + }); + expect(result.current.canExportImage).toBe(true); +}); + +test('returns canCopyClipboard true when user has can_copy_clipboard', () => { + isFeatureEnabled.mockReturnValue(true); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithAllPerms), + }); + expect(result.current.canCopyClipboard).toBe(true); +}); + +test('returns canExportData false when user lacks can_export_data', () => { + isFeatureEnabled.mockReturnValue(true); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithoutExportPerms), + }); + expect(result.current.canExportData).toBe(false); +}); + +test('returns canExportImage false when user lacks can_export_image', () => { + isFeatureEnabled.mockReturnValue(true); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithoutExportPerms), + }); + expect(result.current.canExportImage).toBe(false); +}); + +test('canDownload uses can_export_data when GRANULAR_EXPORT_CONTROLS enabled', () => { + isFeatureEnabled.mockReturnValue(true); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithAllPerms), + }); + expect(result.current.canDownload).toBe(true); +}); + +test('canDownload uses can_csv when GRANULAR_EXPORT_CONTROLS disabled', () => { + isFeatureEnabled.mockReturnValue(false); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithLegacyCsvOnly), + }); + expect(result.current.canDownload).toBe(true); +}); + +test('canDownload false when GRANULAR_EXPORT_CONTROLS enabled but no can_export_data', () => { + isFeatureEnabled.mockReturnValue(true); + const { result } = renderHook(() => usePermissions(), { + wrapper: createWrapper(rolesWithoutExportPerms), + }); + expect(result.current.canDownload).toBe(false); +}); diff --git a/superset-frontend/src/hooks/usePermissions.ts b/superset-frontend/src/hooks/usePermissions.ts index 79636dfa3d2..0d961dc249e 100644 --- a/superset-frontend/src/hooks/usePermissions.ts +++ b/superset-frontend/src/hooks/usePermissions.ts @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core'; import { useSelector } from 'react-redux'; import { RootState } from 'src/dashboard/types'; import { findPermission } from 'src/utils/findPermission'; @@ -30,9 +31,21 @@ export const usePermissions = () => { const canDatasourceSamples = useSelector((state: RootState) => findPermission('can_samples', 'Datasource', state.user?.roles), ); - const canDownload = useSelector((state: RootState) => + const canCsvLegacy = useSelector((state: RootState) => findPermission('can_csv', 'Superset', state.user?.roles), ); + const canExportData = useSelector((state: RootState) => + findPermission('can_export_data', 'Superset', state.user?.roles), + ); + const canExportImage = useSelector((state: RootState) => + findPermission('can_export_image', 'Superset', state.user?.roles), + ); + const canCopyClipboard = useSelector((state: RootState) => + findPermission('can_copy_clipboard', 'Superset', state.user?.roles), + ); + const canDownload = isFeatureEnabled(FeatureFlag.GranularExportControls) + ? canExportData + : canCsvLegacy; const canDrill = useSelector((state: RootState) => findPermission('can_drill', 'Dashboard', state.user?.roles), ); @@ -55,6 +68,9 @@ export const usePermissions = () => { canWriteExploreFormData, canDatasourceSamples, canDownload, + canExportData, + canExportImage, + canCopyClipboard, canDrill, canDrillBy, canDrillToDetail, diff --git a/superset/charts/data/api.py b/superset/charts/data/api.py index 56d92dbc978..707ce79f918 100644 --- a/superset/charts/data/api.py +++ b/superset/charts/data/api.py @@ -299,8 +299,9 @@ class ChartDataRestApi(ChartRestApi): @protect() @statsd_metrics @event_logger.log_this_with_context( - action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" - f".data_from_cache", + action=lambda self, *args, **kwargs: ( + f"{self.__class__.__name__}.data_from_cache" + ), log_to_statsd=False, ) def data_from_cache(self, cache_key: str) -> Response: @@ -405,7 +406,13 @@ class ChartDataRestApi(ChartRestApi): if result_format in ChartDataResultFormat.table_like(): # Verify user has permission to export file - if not security_manager.can_access("can_csv", "Superset"): + if is_feature_enabled("GRANULAR_EXPORT_CONTROLS"): + has_export_perm = security_manager.can_access( + "can_export_data", "Superset" + ) + else: + has_export_perm = security_manager.can_access("can_csv", "Superset") + if not has_export_perm: return self.response_403() if not result["queries"]: diff --git a/superset/config.py b/superset/config.py index 7477f7f4f9e..2216c325b37 100644 --- a/superset/config.py +++ b/superset/config.py @@ -562,6 +562,10 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = { # in addition to relative timeshifts (e.g., "1 day ago") # @lifecycle: development "DATE_RANGE_TIMESHIFTS_ENABLED": False, + # Enable granular export controls (can_export_data, can_export_image, + # can_copy_clipboard) instead of the single can_csv permission + # @lifecycle: development + "GRANULAR_EXPORT_CONTROLS": False, # Enables advanced data type support # @lifecycle: development "ENABLE_ADVANCED_DATA_TYPES": False, diff --git a/superset/migrations/versions/2026-03-02_12-00_a1b2c3d4e5f6_add_granular_export_permissions.py b/superset/migrations/versions/2026-03-02_12-00_a1b2c3d4e5f6_add_granular_export_permissions.py new file mode 100644 index 00000000000..37c207c99ee --- /dev/null +++ b/superset/migrations/versions/2026-03-02_12-00_a1b2c3d4e5f6_add_granular_export_permissions.py @@ -0,0 +1,76 @@ +# 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. +"""add granular export permissions + +Revision ID: a1b2c3d4e5f6 +Revises: 4b2a8c9d3e1f +Create Date: 2026-03-02 12:00:00.000000 + +""" + +# revision identifiers, used by Alembic. +revision = "a1b2c3d4e5f6" +down_revision = "4b2a8c9d3e1f" + +from alembic import op # noqa: E402 +from sqlalchemy.orm import Session # noqa: E402 + +from superset.migrations.shared.security_converge import ( # noqa: E402 + add_pvms, + get_reversed_new_pvms, + get_reversed_pvm_map, + migrate_roles, + Pvm, +) + +NEW_PVMS = { + "Superset": ( + "can_export_data", + "can_export_image", + "can_copy_clipboard", + ) +} + +PVM_MAP = { + Pvm("Superset", "can_csv"): ( + Pvm("Superset", "can_export_data"), + Pvm("Superset", "can_export_image"), + Pvm("Superset", "can_copy_clipboard"), + ), +} + + +def do_upgrade(session: Session) -> None: + add_pvms(session, NEW_PVMS) + migrate_roles(session, PVM_MAP) + + +def do_downgrade(session: Session) -> None: + add_pvms(session, get_reversed_new_pvms(PVM_MAP)) + migrate_roles(session, get_reversed_pvm_map(PVM_MAP)) + + +def upgrade(): + bind = op.get_bind() + session = Session(bind=bind) + do_upgrade(session) + + +def downgrade(): + bind = op.get_bind() + session = Session(bind=bind) + do_downgrade(session) diff --git a/superset/security/manager.py b/superset/security/manager.py index 9f97c810d77..208ffec8576 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -280,6 +280,11 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods "Datasource", } | READ_ONLY_MODEL_VIEWS + GAMMA_EXCLUDED_PVMS = { + ("can_export_data", "Superset"), + ("can_export_image", "Superset"), + } + ADMIN_ONLY_VIEW_MENUS = { "Access Requests", "Action Logs", @@ -396,6 +401,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods SQLLAB_EXTRA_PERMISSION_VIEWS = { ("can_csv", "Superset"), # Deprecated permission remove on 3.0.0 + ("can_export_data", "Superset"), + ("can_copy_clipboard", "Superset"), ("can_read", "Superset"), ("can_read", "Database"), } @@ -1195,6 +1202,9 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods self.add_permission_view_menu("all_database_access", "all_database_access") self.add_permission_view_menu("all_query_access", "all_query_access") self.add_permission_view_menu("can_csv", "Superset") + self.add_permission_view_menu("can_export_data", "Superset") + self.add_permission_view_menu("can_export_image", "Superset") + self.add_permission_view_menu("can_copy_clipboard", "Superset") self.add_permission_view_menu("can_share_dashboard", "Superset") self.add_permission_view_menu("can_share_chart", "Superset") self.add_permission_view_menu("can_sqllab", "Superset") @@ -1476,6 +1486,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods or self._is_admin_only(pvm) or self._is_alpha_only(pvm) or self._is_sql_lab_only(pvm) + or (pvm.permission.name, pvm.view_menu.name) in self.GAMMA_EXCLUDED_PVMS ) or self._is_accessible_to_all(pvm) def _is_sql_lab_only(self, pvm: PermissionView) -> bool: diff --git a/superset/views/core.py b/superset/views/core.py index 690c00bbefd..19e8f3cf1e2 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -489,7 +489,12 @@ class Superset(BaseSupersetView): # slc perms slice_add_perm = security_manager.can_access("can_write", "Chart") slice_overwrite_perm = security_manager.is_owner(slc) if slc else False - slice_download_perm = security_manager.can_access("can_csv", "Superset") + if is_feature_enabled("GRANULAR_EXPORT_CONTROLS"): + slice_download_perm = security_manager.can_access( + "can_export_data", "Superset" + ) + else: + slice_download_perm = security_manager.can_access("can_csv", "Superset") form_data["datasource"] = str(datasource_id) + "__" + cast(str, datasource_type) diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index 2307d522a16..e59dd417b57 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -1571,6 +1571,7 @@ class TestRolePermission(SupersetTestCase): sql_lab_set = get_perm_tuples("sql_lab") assert sql_lab_set == { ("can_activate", "TabStateView"), + ("can_copy_clipboard", "Superset"), ("can_csv", "Superset"), ("can_delete_query", "TabStateView"), ("can_delete", "TabStateView"), @@ -1578,6 +1579,7 @@ class TestRolePermission(SupersetTestCase): ("can_execute_sql_query", "SQLLab"), ("can_export", "SavedQuery"), ("can_export_csv", "SQLLab"), + ("can_export_data", "Superset"), ("can_format_sql", "SQLLab"), ("can_get", "TabStateView"), ("can_get_results", "SQLLab"), diff --git a/tests/unit_tests/security/test_granular_export_permissions.py b/tests/unit_tests/security/test_granular_export_permissions.py new file mode 100644 index 00000000000..6db5e9712b2 --- /dev/null +++ b/tests/unit_tests/security/test_granular_export_permissions.py @@ -0,0 +1,110 @@ +# 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. + +from unittest.mock import MagicMock, patch + +from superset.security.manager import SupersetSecurityManager + + +def test_granular_export_permissions_registered_in_create_custom_permissions( + app_context: None, +) -> None: + """Verify that create_custom_permissions registers all granular export perms.""" + from superset.extensions import appbuilder + + sm = SupersetSecurityManager(appbuilder) + sm.add_permission_view_menu = MagicMock() + + sm.create_custom_permissions() + + calls = [ + (call.args[0], call.args[1]) + for call in sm.add_permission_view_menu.call_args_list + ] + assert ("can_export_data", "Superset") in calls + assert ("can_export_image", "Superset") in calls + assert ("can_copy_clipboard", "Superset") in calls + + +def test_sqllab_extra_permission_views_include_export_perms() -> None: + """Verify SQLLAB_EXTRA_PERMISSION_VIEWS includes granular export perms.""" + assert ("can_export_data", "Superset") in ( + SupersetSecurityManager.SQLLAB_EXTRA_PERMISSION_VIEWS + ) + assert ("can_copy_clipboard", "Superset") in ( + SupersetSecurityManager.SQLLAB_EXTRA_PERMISSION_VIEWS + ) + + +def test_gamma_excluded_pvms_excludes_export_data_and_image() -> None: + """Verify GAMMA_EXCLUDED_PVMS excludes can_export_data and can_export_image.""" + assert ("can_export_data", "Superset") in ( + SupersetSecurityManager.GAMMA_EXCLUDED_PVMS + ) + assert ("can_export_image", "Superset") in ( + SupersetSecurityManager.GAMMA_EXCLUDED_PVMS + ) + + +def test_gamma_excluded_pvms_allows_copy_clipboard() -> None: + """Verify GAMMA_EXCLUDED_PVMS does NOT exclude can_copy_clipboard.""" + assert ("can_copy_clipboard", "Superset") not in ( + SupersetSecurityManager.GAMMA_EXCLUDED_PVMS + ) + + +def test_is_gamma_pvm_excludes_export_data(app_context: None) -> None: + """Verify _is_gamma_pvm returns False for can_export_data.""" + from superset.extensions import appbuilder + + sm = SupersetSecurityManager(appbuilder) + pvm = MagicMock() + pvm.permission.name = "can_export_data" + pvm.view_menu.name = "Superset" + + assert sm._is_gamma_pvm(pvm) is False + + +def test_is_gamma_pvm_excludes_export_image(app_context: None) -> None: + """Verify _is_gamma_pvm returns False for can_export_image.""" + from superset.extensions import appbuilder + + sm = SupersetSecurityManager(appbuilder) + pvm = MagicMock() + pvm.permission.name = "can_export_image" + pvm.view_menu.name = "Superset" + + assert sm._is_gamma_pvm(pvm) is False + + +def test_is_gamma_pvm_allows_copy_clipboard(app_context: None) -> None: + """Verify _is_gamma_pvm returns True for can_copy_clipboard.""" + from superset.extensions import appbuilder + + sm = SupersetSecurityManager(appbuilder) + pvm = MagicMock() + pvm.permission.name = "can_copy_clipboard" + pvm.view_menu.name = "Superset" + # Ensure the pvm doesn't trigger other exclusion checks + with ( + patch.object(sm, "_is_user_defined_permission", return_value=False), + patch.object(sm, "_is_admin_only", return_value=False), + patch.object(sm, "_is_alpha_only", return_value=False), + patch.object(sm, "_is_sql_lab_only", return_value=False), + patch.object(sm, "_is_accessible_to_all", return_value=False), + ): + assert sm._is_gamma_pvm(pvm) is True