From aa55625e2488bb23d7171ec3612ff491e6d3b3ca Mon Sep 17 00:00:00 2001 From: geido Date: Tue, 6 Feb 2024 13:11:30 +0200 Subject: [PATCH 01/15] Add granular permissions --- .../ChartContextMenu/ChartContextMenu.tsx | 13 ++-- .../ChartContextMenu/useContextMenu.test.tsx | 34 ++++++-- .../Chart/DrillBy/DrillByModal.test.tsx | 2 +- .../DrillDetail/DrillDetailModal.test.tsx | 2 +- .../SliceHeaderControls.test.tsx | 78 +++++++++++++++---- .../components/SliceHeaderControls/index.tsx | 23 +++--- superset/security/manager.py | 4 +- tests/integration_tests/security_tests.py | 6 +- 8 files changed, 120 insertions(+), 42 deletions(-) diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx index c03b1a1301c64..1e7fcf3d7f2f9 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx @@ -90,8 +90,11 @@ const ChartContextMenu = ( const canExplore = useSelector((state: RootState) => findPermission('can_explore', 'Superset', state.user?.roles), ); - const canViewDrill = useSelector((state: RootState) => - findPermission('can_view_and_drill', 'Dashboard', state.user?.roles), + const canDrillBy = useSelector((state: RootState) => + findPermission('can_write', 'ExploreFormDataRestAPI', state.user?.roles), + ); + const canDrillToDetail = useSelector((state: RootState) => + findPermission('can_drill_to_detail', 'Dashboard', state.user?.roles), ); const canDatasourceSamples = useSelector((state: RootState) => findPermission('can_samples', 'Datasource', state.user?.roles), @@ -111,17 +114,15 @@ const ChartContextMenu = ( }>({ clientX: 0, clientY: 0 }); const menuItems = []; - const canExploreOrView = canExplore || canViewDrill; const showDrillToDetail = isFeatureEnabled(FeatureFlag.DrillToDetail) && - canExploreOrView && - canDatasourceSamples && + ((canExplore && canDatasourceSamples) || canDrillToDetail) && isDisplayed(ContextMenuItem.DrillToDetail); const showDrillBy = isFeatureEnabled(FeatureFlag.DrillBy) && - canExploreOrView && + (canExplore || canDrillBy) && isDisplayed(ContextMenuItem.DrillBy); const showCrossFilters = diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx index 2e7937ffbda5e..a78d35deceb7b 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx @@ -92,27 +92,45 @@ test('Context menu contains all displayed items only', () => { expect(screen.getByText('Drill by')).toBeInTheDocument(); }); -test('Context menu shows all items tied to can_view_and_drill permission', () => { +test('Context menu shows "Drill by"', () => { + const result = setup({ + roles: { + Admin: [['can_write', 'ExploreFormDataRestAPI']], + }, + }); + result.current.onContextMenu(0, 0, {}); + expect(screen.getByText('Drill by')).toBeInTheDocument(); +}); + +test('Context menu does not show "Drill by"', () => { + const result = setup({ + roles: { + Admin: [['invalid_permission', 'Dashboard']], + }, + }); + result.current.onContextMenu(0, 0, {}); + expect(screen.queryByText('Drill by')).not.toBeInTheDocument(); +}); + +test('Context menu shows "Drill to detail"', () => { const result = setup({ roles: { Admin: [ - ['can_view_and_drill', 'Dashboard'], ['can_samples', 'Datasource'], + ['can_explore', 'Superset'], ], }, }); result.current.onContextMenu(0, 0, {}); expect(screen.getByText('Drill to detail')).toBeInTheDocument(); - expect(screen.getByText('Drill by')).toBeInTheDocument(); - expect(screen.getByText('Add cross-filter')).toBeInTheDocument(); }); -test('Context menu does not show "Drill to detail" without proper permissions', () => { +test('Context menu does not show "Drill to detail"', () => { const result = setup({ - roles: { Admin: [['can_view_and_drill', 'Dashboard']] }, + roles: { + Admin: [['can_explore', 'Superset']], + }, }); result.current.onContextMenu(0, 0, {}); expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); - expect(screen.getByText('Drill by')).toBeInTheDocument(); - expect(screen.getByText('Add cross-filter')).toBeInTheDocument(); }); diff --git a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx index fb43fbfcb3a64..898c033927b43 100644 --- a/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx +++ b/superset-frontend/src/components/Chart/DrillBy/DrillByModal.test.tsx @@ -246,7 +246,7 @@ test('should render "Edit chart" as disabled without can_explore permission', as { user: { ...drillByModalState.user, - roles: { Admin: [['test_invalid_role', 'Superset']] }, + roles: { Admin: [['invalid_permission', 'Superset']] }, }, }, ); diff --git a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx index 5b6cc71241b69..f5185972cc3d3 100644 --- a/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx +++ b/superset-frontend/src/components/Chart/DrillDetail/DrillDetailModal.test.tsx @@ -108,7 +108,7 @@ test('should render "Edit chart" as disabled without can_explore permission', as await renderModal({ user: { ...drillToDetailModalState.user, - roles: { Admin: [['test_invalid_role', 'Superset']] }, + roles: { Admin: [['invalid_permission', 'Superset']] }, }, }); expect(screen.getByRole('button', { name: 'Edit chart' })).toBeDisabled(); diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx index 31a3cad9f6c32..0970904843a9f 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx @@ -19,9 +19,9 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; -import { getMockStore } from 'spec/fixtures/mockStore'; import { render, screen } from 'spec/helpers/testing-library'; import { FeatureFlag } from '@superset-ui/core'; +import mockState from 'spec/fixtures/mockState'; import SliceHeaderControls, { SliceHeaderControlsProps } from '.'; jest.mock('src/components/Dropdown', () => { @@ -104,8 +104,6 @@ const renderWrapper = ( roles?: Record, ) => { const props = overrideProps || createProps(); - const store = getMockStore(); - const mockState = store.getState(); return render(, { useRedux: true, useRouter: true, @@ -113,7 +111,9 @@ const renderWrapper = ( ...mockState, user: { ...mockState.user, - roles: roles ?? mockState.user.roles, + roles: roles ?? { + Admin: [['can_samples', 'Datasource']], + }, }, }, }); @@ -310,18 +310,18 @@ test('Should show "Drill to detail"', () => { global.featureFlags = { [FeatureFlag.DrillToDetail]: true, }; - const props = createProps(); + const props = { + ...createProps(), + supersetCanExplore: true, + }; props.slice.slice_id = 18; renderWrapper(props, { - Admin: [ - ['can_view_and_drill', 'Dashboard'], - ['can_samples', 'Datasource'], - ], + Admin: [['can_samples', 'Datasource']], }); expect(screen.getByText('Drill to detail')).toBeInTheDocument(); }); -test('Should show menu items tied to can_view_and_drill permission', () => { +test('Should not show "Drill to detail"', () => { // @ts-ignore global.featureFlags = { [FeatureFlag.DrillToDetail]: true, @@ -332,21 +332,71 @@ test('Should show menu items tied to can_view_and_drill permission', () => { }; props.slice.slice_id = 18; renderWrapper(props, { - Admin: [['can_view_and_drill', 'Dashboard']], + Admin: [['invalid_permission', 'Dashboard']], + }); + expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); +}); + +test('Should show "View query"', () => { + const props = { + ...createProps(), + supersetCanExplore: false, + }; + props.slice.slice_id = 18; + renderWrapper(props, { + Admin: [['can_view_query', 'Dashboard']], }); expect(screen.getByText('View query')).toBeInTheDocument(); +}); + +test('Should not show "View query"', () => { + const props = { + ...createProps(), + supersetCanExplore: false, + }; + props.slice.slice_id = 18; + renderWrapper(props, { + Admin: [['invalid_permission', 'Dashboard']], + }); + expect(screen.queryByText('View query')).not.toBeInTheDocument(); +}); + +test('Should show "View as table"', () => { + const props = { + ...createProps(), + supersetCanExplore: false, + }; + props.slice.slice_id = 18; + renderWrapper(props, { + Admin: [['can_view_table', 'Dashboard']], + }); expect(screen.getByText('View as table')).toBeInTheDocument(); - expect(screen.queryByText('Drill to detail')).not.toBeInTheDocument(); }); -test('Should not show the "Edit chart" without proper permissions', () => { +test('Should not show "View as table"', () => { const props = { ...createProps(), supersetCanExplore: false, }; props.slice.slice_id = 18; renderWrapper(props, { - Admin: [['can_view_and_drill', 'Dashboard']], + Admin: [['invalid_permission', 'Dashboard']], + }); + expect(screen.queryByText('View as table')).not.toBeInTheDocument(); +}); + +test('Should not show the "Edit chart" button', () => { + const props = { + ...createProps(), + supersetCanExplore: false, + }; + props.slice.slice_id = 18; + renderWrapper(props, { + Admin: [ + ['can_samples', 'Datasource'], + ['can_view_query', 'Dashboard'], + ['can_view_table', 'Dashboard'], + ], }); expect(screen.queryByText('Edit chart')).not.toBeInTheDocument(); }); diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index d4b9a1a0b935c..5043ab26a3475 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -273,13 +273,19 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => { getChartMetadataRegistry() .get(props.slice.viz_type) ?.behaviors?.includes(Behavior.InteractiveChart); - const canViewDrill = useSelector((state: RootState) => - findPermission('can_view_and_drill', 'Dashboard', state.user?.roles), + const canExplore = props.supersetCanExplore; + const canDrillToDetail = useSelector((state: RootState) => + findPermission('can_drill_to_detail', 'Dashboard', state.user?.roles), ); - const canExploreOrView = props.supersetCanExplore || canViewDrill; const canDatasourceSamples = useSelector((state: RootState) => findPermission('can_samples', 'Datasource', state.user?.roles), ); + const canViewQuery = useSelector((state: RootState) => + findPermission('can_view_query', 'Dashboard', state.user?.roles), + ); + const canViewTable = useSelector((state: RootState) => + findPermission('can_view_table', 'Dashboard', state.user?.roles), + ); const refreshChart = () => { if (props.updatedDttm) { props.forceRefresh(props.slice.slice_id, props.dashboardId); @@ -429,7 +435,7 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => { )} - {props.supersetCanExplore && ( + {canExplore && ( @@ -448,7 +454,7 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => { )} - {canExploreOrView && ( + {(canExplore || canViewQuery) && ( { )} - {canExploreOrView && ( + {(canExplore || canViewTable) && ( { )} {isFeatureEnabled(FeatureFlag.DrillToDetail) && - canExploreOrView && - canDatasourceSamples && ( + ((canExplore && canDatasourceSamples) || canDrillToDetail) && ( )} - {(slice.description || canExploreOrView) && } + {(slice.description || canExplore) && } {supersetCanShare && ( diff --git a/superset/security/manager.py b/superset/security/manager.py index 8268b19411b9c..ceaa99f8a27ec 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -726,7 +726,9 @@ def create_custom_permissions(self) -> None: self.add_permission_view_menu("can_csv", "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_view_and_drill", "Dashboard") + self.add_permission_view_menu("can_view_query", "Dashboard") + self.add_permission_view_menu("can_view_table", "Dashboard") + self.add_permission_view_menu("can_drill_to_detail", "Dashboard") def create_missing_perms(self) -> None: """ diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index d9c98aa253c6a..fb64f063305e0 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -1337,7 +1337,8 @@ def assert_can_gamma(self, perm_set): self.assertIn(("can_explore_json", "Superset"), perm_set) self.assertIn(("can_explore_json", "Superset"), perm_set) self.assertIn(("can_userinfo", "UserDBModelView"), perm_set) - self.assertIn(("can_view_and_drill", "Dashboard"), perm_set) + self.assertIn(("can_view_table", "Dashboard"), perm_set) + self.assertIn(("can_view_query", "Dashboard"), perm_set) self.assert_can_menu("Databases", perm_set) self.assert_can_menu("Datasets", perm_set) self.assert_can_menu("Data", perm_set) @@ -1505,7 +1506,8 @@ def test_gamma_permissions(self): self.assertIn(("can_share_dashboard", "Superset"), gamma_perm_set) self.assertIn(("can_explore_json", "Superset"), gamma_perm_set) self.assertIn(("can_userinfo", "UserDBModelView"), gamma_perm_set) - self.assertIn(("can_view_and_drill", "Dashboard"), gamma_perm_set) + self.assertIn(("can_view_table", "Dashboard"), gamma_perm_set) + self.assertIn(("can_view_query", "Dashboard"), gamma_perm_set) def test_views_are_secured(self): """Preventing the addition of unsecured views without has_access decorator""" From 1cd12c6d443e16ca2360397ca15e7ef3f1d55a8b Mon Sep 17 00:00:00 2001 From: geido Date: Tue, 6 Feb 2024 13:11:49 +0200 Subject: [PATCH 02/15] Override API permissions POC --- superset/utils/decorators.py | 25 +++++++++++++++++++++++++ superset/views/datasource/views.py | 3 ++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/superset/utils/decorators.py b/superset/utils/decorators.py index 43b6909caf3fb..74eec9b4df8cc 100644 --- a/superset/utils/decorators.py +++ b/superset/utils/decorators.py @@ -20,11 +20,14 @@ import time from collections.abc import Iterator from contextlib import contextmanager +from functools import wraps from typing import Any, Callable, TYPE_CHECKING from uuid import UUID from flask import current_app, g, Response +from flask_appbuilder.security.decorators import has_access_api +from superset import security_manager from superset.utils import core as utils from superset.utils.dates import now_as_float @@ -191,3 +194,25 @@ def wrapped(*args: Any, **kwargs: Any) -> Any: def on_security_exception(self: Any, ex: Exception) -> Response: return self.response(403, **{"message": utils.error_msg_from_exception(ex)}) + + +def has_api_override_permission(permissions_targets: list[tuple[str, str]]) -> Callable: + """ + Decorator to check for multiple override permissions, each tied to a specific target. + :param permissions_targets: A list of tuples where each tuple contains [permission, target_view]. + """ + + def decorator(f: Callable) -> Callable: + @wraps(f) + def decorated_function(*args, **kwargs) -> Callable: + # Iterate through the list of permission-target pairs + for permission, target_view in permissions_targets: + # Check for each custom override permission with its specific target + if security_manager.can_access(permission, target_view): + return f(*args, **kwargs) + # Fallback to the standard has_access_api decorator if none of the override permissions are present + return has_access_api(f)(*args, **kwargs) + + return decorated_function + + return decorator diff --git a/superset/views/datasource/views.py b/superset/views/datasource/views.py index 2e46faf0af9dc..1d67c6e1a1d7f 100644 --- a/superset/views/datasource/views.py +++ b/superset/views/datasource/views.py @@ -39,6 +39,7 @@ from superset.models.core import Database from superset.superset_typing import FlaskResponse from superset.utils.core import DatasourceType +from superset.utils.decorators import has_api_override_permission from superset.views.base import ( api, BaseSupersetView, @@ -189,7 +190,7 @@ def external_metadata_by_name(self, **kwargs: Any) -> FlaskResponse: return self.json_response(external_metadata) @expose("/samples", methods=("POST",)) - @has_access_api + @has_api_override_permission([("can_drill_to_detail", "Dashboard")]) @api @handle_api_exception def samples(self) -> FlaskResponse: From c3cd6f4b76293577ba84d772613f1e26551cf98a Mon Sep 17 00:00:00 2001 From: geido Date: Tue, 6 Feb 2024 13:26:47 +0200 Subject: [PATCH 03/15] Rename canDrillBy to permission name --- .../components/Chart/ChartContextMenu/ChartContextMenu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx index 1e7fcf3d7f2f9..63a5f2b25bf4c 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx @@ -90,7 +90,7 @@ const ChartContextMenu = ( const canExplore = useSelector((state: RootState) => findPermission('can_explore', 'Superset', state.user?.roles), ); - const canDrillBy = useSelector((state: RootState) => + const canWriteExploreFormData = useSelector((state: RootState) => findPermission('can_write', 'ExploreFormDataRestAPI', state.user?.roles), ); const canDrillToDetail = useSelector((state: RootState) => @@ -122,7 +122,7 @@ const ChartContextMenu = ( const showDrillBy = isFeatureEnabled(FeatureFlag.DrillBy) && - (canExplore || canDrillBy) && + (canExplore || canWriteExploreFormData) && isDisplayed(ContextMenuItem.DrillBy); const showCrossFilters = From 9902ed3bb2d85f86b61d6354dc98220fe5d9c4e2 Mon Sep 17 00:00:00 2001 From: geido Date: Tue, 6 Feb 2024 17:26:40 +0200 Subject: [PATCH 04/15] Improve permissions --- .../ChartContextMenu/ChartContextMenu.tsx | 9 ++++---- .../ChartContextMenu/useContextMenu.test.tsx | 6 ++++- .../components/SliceHeaderControls/index.tsx | 17 ++++++-------- superset/security/manager.py | 1 - superset/utils/decorators.py | 23 ------------------- superset/views/datasource/views.py | 1 - 6 files changed, 16 insertions(+), 41 deletions(-) diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx index 63a5f2b25bf4c..e48fd8293cb0f 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx @@ -93,12 +93,11 @@ const ChartContextMenu = ( const canWriteExploreFormData = useSelector((state: RootState) => findPermission('can_write', 'ExploreFormDataRestAPI', state.user?.roles), ); - const canDrillToDetail = useSelector((state: RootState) => - findPermission('can_drill_to_detail', 'Dashboard', state.user?.roles), - ); const canDatasourceSamples = useSelector((state: RootState) => findPermission('can_samples', 'Datasource', state.user?.roles), ); + const canDrillBy = canExplore && canWriteExploreFormData; + const canDrillToDetail = canExplore && canDatasourceSamples; const crossFiltersEnabled = useSelector( ({ dashboardInfo }) => dashboardInfo.crossFiltersEnabled, ); @@ -117,12 +116,12 @@ const ChartContextMenu = ( const showDrillToDetail = isFeatureEnabled(FeatureFlag.DrillToDetail) && - ((canExplore && canDatasourceSamples) || canDrillToDetail) && + canDrillToDetail && isDisplayed(ContextMenuItem.DrillToDetail); const showDrillBy = isFeatureEnabled(FeatureFlag.DrillBy) && - (canExplore || canWriteExploreFormData) && + canDrillBy && isDisplayed(ContextMenuItem.DrillBy); const showCrossFilters = diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx index a78d35deceb7b..4662bbbe169b1 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx @@ -64,6 +64,7 @@ const setup = ({ Admin: [ ['can_explore', 'Superset'], ['can_samples', 'Datasource'], + ['can_write', 'ExploreFormDataRestAPI'], ], }, }, @@ -95,7 +96,10 @@ test('Context menu contains all displayed items only', () => { test('Context menu shows "Drill by"', () => { const result = setup({ roles: { - Admin: [['can_write', 'ExploreFormDataRestAPI']], + Admin: [ + ['can_write', 'ExploreFormDataRestAPI'], + ['can_explore', 'Superset'], + ], }, }); result.current.onContextMenu(0, 0, {}); diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 5043ab26a3475..7636ca64518f0 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -274,12 +274,10 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => { .get(props.slice.viz_type) ?.behaviors?.includes(Behavior.InteractiveChart); const canExplore = props.supersetCanExplore; - const canDrillToDetail = useSelector((state: RootState) => - findPermission('can_drill_to_detail', 'Dashboard', state.user?.roles), - ); const canDatasourceSamples = useSelector((state: RootState) => findPermission('can_samples', 'Datasource', state.user?.roles), ); + const canDrillToDetail = canExplore && canDatasourceSamples; const canViewQuery = useSelector((state: RootState) => findPermission('can_view_query', 'Dashboard', state.user?.roles), ); @@ -491,13 +489,12 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => { )} - {isFeatureEnabled(FeatureFlag.DrillToDetail) && - ((canExplore && canDatasourceSamples) || canDrillToDetail) && ( - - )} + {isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail && ( + + )} {(slice.description || canExplore) && } diff --git a/superset/security/manager.py b/superset/security/manager.py index ceaa99f8a27ec..e6410032eaf82 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -728,7 +728,6 @@ def create_custom_permissions(self) -> None: self.add_permission_view_menu("can_share_chart", "Superset") self.add_permission_view_menu("can_view_query", "Dashboard") self.add_permission_view_menu("can_view_table", "Dashboard") - self.add_permission_view_menu("can_drill_to_detail", "Dashboard") def create_missing_perms(self) -> None: """ diff --git a/superset/utils/decorators.py b/superset/utils/decorators.py index 74eec9b4df8cc..0a81106f48c9a 100644 --- a/superset/utils/decorators.py +++ b/superset/utils/decorators.py @@ -20,7 +20,6 @@ import time from collections.abc import Iterator from contextlib import contextmanager -from functools import wraps from typing import Any, Callable, TYPE_CHECKING from uuid import UUID @@ -194,25 +193,3 @@ def wrapped(*args: Any, **kwargs: Any) -> Any: def on_security_exception(self: Any, ex: Exception) -> Response: return self.response(403, **{"message": utils.error_msg_from_exception(ex)}) - - -def has_api_override_permission(permissions_targets: list[tuple[str, str]]) -> Callable: - """ - Decorator to check for multiple override permissions, each tied to a specific target. - :param permissions_targets: A list of tuples where each tuple contains [permission, target_view]. - """ - - def decorator(f: Callable) -> Callable: - @wraps(f) - def decorated_function(*args, **kwargs) -> Callable: - # Iterate through the list of permission-target pairs - for permission, target_view in permissions_targets: - # Check for each custom override permission with its specific target - if security_manager.can_access(permission, target_view): - return f(*args, **kwargs) - # Fallback to the standard has_access_api decorator if none of the override permissions are present - return has_access_api(f)(*args, **kwargs) - - return decorated_function - - return decorator diff --git a/superset/views/datasource/views.py b/superset/views/datasource/views.py index 1d67c6e1a1d7f..57c4fc5cc400e 100644 --- a/superset/views/datasource/views.py +++ b/superset/views/datasource/views.py @@ -190,7 +190,6 @@ def external_metadata_by_name(self, **kwargs: Any) -> FlaskResponse: return self.json_response(external_metadata) @expose("/samples", methods=("POST",)) - @has_api_override_permission([("can_drill_to_detail", "Dashboard")]) @api @handle_api_exception def samples(self) -> FlaskResponse: From 86444e666f67a03413691867433ce0d233667fe1 Mon Sep 17 00:00:00 2001 From: geido Date: Tue, 6 Feb 2024 17:38:16 +0200 Subject: [PATCH 05/15] Clean up --- superset/views/datasource/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/superset/views/datasource/views.py b/superset/views/datasource/views.py index 57c4fc5cc400e..c7fc7b08f6b03 100644 --- a/superset/views/datasource/views.py +++ b/superset/views/datasource/views.py @@ -39,7 +39,6 @@ from superset.models.core import Database from superset.superset_typing import FlaskResponse from superset.utils.core import DatasourceType -from superset.utils.decorators import has_api_override_permission from superset.views.base import ( api, BaseSupersetView, From 4dc1d3c8071b9de874bee3310928a54ac50e1a03 Mon Sep 17 00:00:00 2001 From: geido Date: Tue, 6 Feb 2024 17:47:48 +0200 Subject: [PATCH 06/15] Lint --- superset/utils/decorators.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/superset/utils/decorators.py b/superset/utils/decorators.py index 0a81106f48c9a..43b6909caf3fb 100644 --- a/superset/utils/decorators.py +++ b/superset/utils/decorators.py @@ -24,9 +24,7 @@ from uuid import UUID from flask import current_app, g, Response -from flask_appbuilder.security.decorators import has_access_api -from superset import security_manager from superset.utils import core as utils from superset.utils.dates import now_as_float From d66dd5075b2f1178b3aff1f940436846b6bcf977 Mon Sep 17 00:00:00 2001 From: geido Date: Tue, 6 Feb 2024 17:56:24 +0200 Subject: [PATCH 07/15] Protect samples route --- superset/views/datasource/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/superset/views/datasource/views.py b/superset/views/datasource/views.py index c7fc7b08f6b03..2e46faf0af9dc 100644 --- a/superset/views/datasource/views.py +++ b/superset/views/datasource/views.py @@ -189,6 +189,7 @@ def external_metadata_by_name(self, **kwargs: Any) -> FlaskResponse: return self.json_response(external_metadata) @expose("/samples", methods=("POST",)) + @has_access_api @api @handle_api_exception def samples(self) -> FlaskResponse: From c68774c0dbd0ed417a318ca1079be8fa1f4e9e62 Mon Sep 17 00:00:00 2001 From: geido Date: Tue, 6 Feb 2024 18:36:08 +0200 Subject: [PATCH 08/15] Fix permission name --- .../components/Chart/ChartContextMenu/ChartContextMenu.tsx | 2 +- .../components/Chart/ChartContextMenu/useContextMenu.test.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx index e48fd8293cb0f..337047c67df24 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu/ChartContextMenu.tsx @@ -91,7 +91,7 @@ const ChartContextMenu = ( findPermission('can_explore', 'Superset', state.user?.roles), ); const canWriteExploreFormData = useSelector((state: RootState) => - findPermission('can_write', 'ExploreFormDataRestAPI', state.user?.roles), + findPermission('can_write', 'ExploreFormDataRestApi', state.user?.roles), ); const canDatasourceSamples = useSelector((state: RootState) => findPermission('can_samples', 'Datasource', state.user?.roles), diff --git a/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx b/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx index 4662bbbe169b1..f0157e8073e74 100644 --- a/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx +++ b/superset-frontend/src/components/Chart/ChartContextMenu/useContextMenu.test.tsx @@ -64,7 +64,7 @@ const setup = ({ Admin: [ ['can_explore', 'Superset'], ['can_samples', 'Datasource'], - ['can_write', 'ExploreFormDataRestAPI'], + ['can_write', 'ExploreFormDataRestApi'], ], }, }, @@ -97,7 +97,7 @@ test('Context menu shows "Drill by"', () => { const result = setup({ roles: { Admin: [ - ['can_write', 'ExploreFormDataRestAPI'], + ['can_write', 'ExploreFormDataRestApi'], ['can_explore', 'Superset'], ], }, From 35c08b942cd0e7f909aa84f6da33a50f938e5dc6 Mon Sep 17 00:00:00 2001 From: geido Date: Wed, 7 Feb 2024 16:56:42 +0200 Subject: [PATCH 09/15] Rename can_view_table permission --- .../SliceHeaderControls/SliceHeaderControls.test.tsx | 4 ++-- .../src/dashboard/components/SliceHeaderControls/index.tsx | 2 +- superset/security/manager.py | 2 +- tests/integration_tests/security_tests.py | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx index 0970904843a9f..6006489a9dd19 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/SliceHeaderControls.test.tsx @@ -368,7 +368,7 @@ test('Should show "View as table"', () => { }; props.slice.slice_id = 18; renderWrapper(props, { - Admin: [['can_view_table', 'Dashboard']], + Admin: [['can_view_chart_as_table', 'Dashboard']], }); expect(screen.getByText('View as table')).toBeInTheDocument(); }); @@ -395,7 +395,7 @@ test('Should not show the "Edit chart" button', () => { Admin: [ ['can_samples', 'Datasource'], ['can_view_query', 'Dashboard'], - ['can_view_table', 'Dashboard'], + ['can_view_chart_as_table', 'Dashboard'], ], }); expect(screen.queryByText('Edit chart')).not.toBeInTheDocument(); diff --git a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx index 7636ca64518f0..e2480d0431b77 100644 --- a/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx +++ b/superset-frontend/src/dashboard/components/SliceHeaderControls/index.tsx @@ -282,7 +282,7 @@ const SliceHeaderControls = (props: SliceHeaderControlsPropsWithRouter) => { findPermission('can_view_query', 'Dashboard', state.user?.roles), ); const canViewTable = useSelector((state: RootState) => - findPermission('can_view_table', 'Dashboard', state.user?.roles), + findPermission('can_view_chart_as_table', 'Dashboard', state.user?.roles), ); const refreshChart = () => { if (props.updatedDttm) { diff --git a/superset/security/manager.py b/superset/security/manager.py index e6410032eaf82..1d16952608d3e 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -727,7 +727,7 @@ def create_custom_permissions(self) -> None: self.add_permission_view_menu("can_share_dashboard", "Superset") self.add_permission_view_menu("can_share_chart", "Superset") self.add_permission_view_menu("can_view_query", "Dashboard") - self.add_permission_view_menu("can_view_table", "Dashboard") + self.add_permission_view_menu("can_view_chart_as_table", "Dashboard") def create_missing_perms(self) -> None: """ diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index fb64f063305e0..ece9afcccb502 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -1337,7 +1337,7 @@ def assert_can_gamma(self, perm_set): self.assertIn(("can_explore_json", "Superset"), perm_set) self.assertIn(("can_explore_json", "Superset"), perm_set) self.assertIn(("can_userinfo", "UserDBModelView"), perm_set) - self.assertIn(("can_view_table", "Dashboard"), perm_set) + self.assertIn(("can_view_chart_as_table", "Dashboard"), perm_set) self.assertIn(("can_view_query", "Dashboard"), perm_set) self.assert_can_menu("Databases", perm_set) self.assert_can_menu("Datasets", perm_set) @@ -1506,7 +1506,7 @@ def test_gamma_permissions(self): self.assertIn(("can_share_dashboard", "Superset"), gamma_perm_set) self.assertIn(("can_explore_json", "Superset"), gamma_perm_set) self.assertIn(("can_userinfo", "UserDBModelView"), gamma_perm_set) - self.assertIn(("can_view_table", "Dashboard"), gamma_perm_set) + self.assertIn(("can_view_chart_as_table", "Dashboard"), gamma_perm_set) self.assertIn(("can_view_query", "Dashboard"), gamma_perm_set) def test_views_are_secured(self): From 5078188577f1f6edd558291f1afc9d1f2295a654 Mon Sep 17 00:00:00 2001 From: geido Date: Wed, 7 Feb 2024 17:31:41 +0200 Subject: [PATCH 10/15] Add migration --- ...8_migrate_can_view_and_drill_permission.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py diff --git a/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py b/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py new file mode 100644 index 0000000000000..92a52cccda5b9 --- /dev/null +++ b/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py @@ -0,0 +1,72 @@ +# 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. +"""Migrate can_view_and_drill permission + +Revision ID: 87d38ad83218 +Revises: 1cf8e4344e2b +Create Date: 2024-02-07 17:13:20.937186 + +""" + +# revision identifiers, used by Alembic. +revision = '87d38ad83218' +down_revision = '1cf8e4344e2b' + +from alembic import op +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session + +from superset.migrations.shared.security_converge import ( + add_pvms, + get_reversed_new_pvms, + get_reversed_pvm_map, + migrate_roles, + Pvm, +) + +NEW_PVMS = {"Dashboard": ("can_view_chart_as_table",)} + +PVM_MAP = { + Pvm("Dashboard", "can_view_and_drill"): (Pvm("Dashboard", "can_view_chart_as_table"),), +} + +def upgrade(): + bind = op.get_bind() + session = Session(bind=bind) + + add_pvms(session, NEW_PVMS) + + migrate_roles(session, PVM_MAP) + + try: + session.commit() + except SQLAlchemyError as ex: + session.rollback() + raise Exception(f"An error occurred while upgrading permissions: {ex}") + +def downgrade(): + bind = op.get_bind() + session = Session(bind=bind) + + add_pvms(session, get_reversed_new_pvms(PVM_MAP)) + migrate_roles(session, get_reversed_pvm_map(PVM_MAP)) + try: + session.commit() + except SQLAlchemyError as ex: + print(f"An error occurred while downgrading permissions: {ex}") + session.rollback() + pass From 2ed06762871f64b60e9704452db9edb2a3cc6dc9 Mon Sep 17 00:00:00 2001 From: geido Date: Wed, 7 Feb 2024 17:42:44 +0200 Subject: [PATCH 11/15] Black --- ...d38ad83218_migrate_can_view_and_drill_permission.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py b/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py index 92a52cccda5b9..eb8bd91ee4ecc 100644 --- a/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py +++ b/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py @@ -23,8 +23,8 @@ """ # revision identifiers, used by Alembic. -revision = '87d38ad83218' -down_revision = '1cf8e4344e2b' +revision = "87d38ad83218" +down_revision = "1cf8e4344e2b" from alembic import op from sqlalchemy.exc import SQLAlchemyError @@ -41,9 +41,12 @@ NEW_PVMS = {"Dashboard": ("can_view_chart_as_table",)} PVM_MAP = { - Pvm("Dashboard", "can_view_and_drill"): (Pvm("Dashboard", "can_view_chart_as_table"),), + Pvm("Dashboard", "can_view_and_drill"): ( + Pvm("Dashboard", "can_view_chart_as_table"), + ), } + def upgrade(): bind = op.get_bind() session = Session(bind=bind) @@ -58,6 +61,7 @@ def upgrade(): session.rollback() raise Exception(f"An error occurred while upgrading permissions: {ex}") + def downgrade(): bind = op.get_bind() session = Session(bind=bind) From e91ea72574aa8660ab5cc9f49b5a6c21323fec69 Mon Sep 17 00:00:00 2001 From: geido Date: Fri, 9 Feb 2024 11:25:40 +0200 Subject: [PATCH 12/15] Add can_view_query migration permission --- ...7_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py | 1 + 1 file changed, 1 insertion(+) diff --git a/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py b/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py index eb8bd91ee4ecc..eed17e372239c 100644 --- a/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py +++ b/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py @@ -43,6 +43,7 @@ PVM_MAP = { Pvm("Dashboard", "can_view_and_drill"): ( Pvm("Dashboard", "can_view_chart_as_table"), + Pvm("Dashboard", "can_view_query"), ), } From 2e10102a948b54757b3fb519b7ad9fa33645c3b3 Mon Sep 17 00:00:00 2001 From: geido Date: Fri, 9 Feb 2024 13:21:14 +0200 Subject: [PATCH 13/15] Add migration test --- ...8_migrate_can_view_and_drill_permission.py | 18 ++++++--- ...te_can_view_and_drill_permission__tests.py | 39 +++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 tests/integration_tests/migrations/87d38ad83218_migrate_can_view_and_drill_permission__tests.py diff --git a/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py b/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py index eed17e372239c..abc7adc660cd3 100644 --- a/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py +++ b/superset/migrations/versions/2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission.py @@ -48,13 +48,21 @@ } +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) - add_pvms(session, NEW_PVMS) - - migrate_roles(session, PVM_MAP) + do_upgrade(session) try: session.commit() @@ -67,8 +75,8 @@ def downgrade(): bind = op.get_bind() session = Session(bind=bind) - add_pvms(session, get_reversed_new_pvms(PVM_MAP)) - migrate_roles(session, get_reversed_pvm_map(PVM_MAP)) + do_downgrade(session) + try: session.commit() except SQLAlchemyError as ex: diff --git a/tests/integration_tests/migrations/87d38ad83218_migrate_can_view_and_drill_permission__tests.py b/tests/integration_tests/migrations/87d38ad83218_migrate_can_view_and_drill_permission__tests.py new file mode 100644 index 0000000000000..64136d337d0c4 --- /dev/null +++ b/tests/integration_tests/migrations/87d38ad83218_migrate_can_view_and_drill_permission__tests.py @@ -0,0 +1,39 @@ +from importlib import import_module + +from superset import db +from superset.migrations.shared.security_converge import _find_pvm, Permission, PermissionView, ViewMenu +from tests.integration_tests.test_app import app + +migration_module = import_module( + "superset.migrations.versions." + "2024-02-07_17-13_87d38ad83218_migrate_can_view_and_drill_permission" +) + +upgrade = migration_module.do_upgrade +downgrade = migration_module.do_downgrade + +def test_migration_upgrade(): + with app.app_context(): + pre_perm = PermissionView( + permission=Permission(name="can_view_and_drill"), + view_menu=db.session.query(ViewMenu).filter_by(name="Dashboard").one(), + ) + db.session.add(pre_perm) + db.session.commit() + + assert _find_pvm(db.session, "Dashboard", "can_view_and_drill") is not None + + upgrade(db.session) + + assert _find_pvm(db.session, "Dashboard", "can_view_chart_as_table") is not None + assert _find_pvm(db.session, "Dashboard", "can_view_query") is not None + assert _find_pvm(db.session, "Dashboard", "can_view_and_drill") is None + +def test_migration_downgrade(): + with app.app_context(): + downgrade(db.session) + + assert _find_pvm(db.session, "Dashboard", "can_view_chart_as_table") is None + assert _find_pvm(db.session, "Dashboard", "can_view_query") is None + assert _find_pvm(db.session, "Dashboard", "can_view_and_drill") is not None + From e4e1c144dca45a89d7821427ce99ecdaa997d6c4 Mon Sep 17 00:00:00 2001 From: geido Date: Fri, 9 Feb 2024 13:23:27 +0200 Subject: [PATCH 14/15] Add license --- ...grate_can_view_and_drill_permission__tests.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/integration_tests/migrations/87d38ad83218_migrate_can_view_and_drill_permission__tests.py b/tests/integration_tests/migrations/87d38ad83218_migrate_can_view_and_drill_permission__tests.py index 64136d337d0c4..b20a6faa7b8cb 100644 --- a/tests/integration_tests/migrations/87d38ad83218_migrate_can_view_and_drill_permission__tests.py +++ b/tests/integration_tests/migrations/87d38ad83218_migrate_can_view_and_drill_permission__tests.py @@ -1,3 +1,19 @@ +# 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 importlib import import_module from superset import db From 3c4cdb2d70202834f0755c1879802e0012dd172d Mon Sep 17 00:00:00 2001 From: geido Date: Fri, 9 Feb 2024 13:31:58 +0200 Subject: [PATCH 15/15] Black --- ...218_migrate_can_view_and_drill_permission__tests.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests/migrations/87d38ad83218_migrate_can_view_and_drill_permission__tests.py b/tests/integration_tests/migrations/87d38ad83218_migrate_can_view_and_drill_permission__tests.py index b20a6faa7b8cb..e8b825a3e4a75 100644 --- a/tests/integration_tests/migrations/87d38ad83218_migrate_can_view_and_drill_permission__tests.py +++ b/tests/integration_tests/migrations/87d38ad83218_migrate_can_view_and_drill_permission__tests.py @@ -17,7 +17,12 @@ from importlib import import_module from superset import db -from superset.migrations.shared.security_converge import _find_pvm, Permission, PermissionView, ViewMenu +from superset.migrations.shared.security_converge import ( + _find_pvm, + Permission, + PermissionView, + ViewMenu, +) from tests.integration_tests.test_app import app migration_module = import_module( @@ -28,6 +33,7 @@ upgrade = migration_module.do_upgrade downgrade = migration_module.do_downgrade + def test_migration_upgrade(): with app.app_context(): pre_perm = PermissionView( @@ -45,6 +51,7 @@ def test_migration_upgrade(): assert _find_pvm(db.session, "Dashboard", "can_view_query") is not None assert _find_pvm(db.session, "Dashboard", "can_view_and_drill") is None + def test_migration_downgrade(): with app.app_context(): downgrade(db.session) @@ -52,4 +59,3 @@ def test_migration_downgrade(): assert _find_pvm(db.session, "Dashboard", "can_view_chart_as_table") is None assert _find_pvm(db.session, "Dashboard", "can_view_query") is None assert _find_pvm(db.session, "Dashboard", "can_view_and_drill") is not None -