diff --git a/superset-frontend/src/dashboard/util/findPermission.ts b/superset-frontend/src/dashboard/util/findPermission.ts index 8f28a03c99337..d3a8b61eca94a 100644 --- a/superset-frontend/src/dashboard/util/findPermission.ts +++ b/superset-frontend/src/dashboard/util/findPermission.ts @@ -36,7 +36,7 @@ export default findPermission; // but is hardcoded in backend logic already, so... const ADMIN_ROLE_NAME = 'admin'; -const isUserAdmin = (user: UserWithPermissionsAndRoles) => +export const isUserAdmin = (user: UserWithPermissionsAndRoles) => Object.keys(user.roles).some(role => role.toLowerCase() === ADMIN_ROLE_NAME); const isUserDashboardOwner = ( diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx index fa8721e9e3dad..5fe6ead7fdf08 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.test.jsx @@ -18,7 +18,7 @@ */ import React from 'react'; import thunk from 'redux-thunk'; -import * as redux from 'react-redux'; +import * as reactRedux from 'react-redux'; import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; import { Provider } from 'react-redux'; @@ -34,6 +34,7 @@ import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import { act } from 'react-dom/test-utils'; // store needed for withToasts(DatabaseList) + const mockStore = configureStore([thunk]); const store = mockStore({}); @@ -63,10 +64,6 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); -const mockUser = { - userId: 1, -}; - fetchMock.get(databasesInfoEndpoint, { permissions: ['can_write'], }); @@ -91,7 +88,13 @@ fetchMock.get(databaseRelatedEndpoint, { }, }); -const useSelectorMock = jest.spyOn(redux, 'useSelector'); +fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', + {}, +); + +const useSelectorMock = jest.spyOn(reactRedux, 'useSelector'); +const userSelectorMock = jest.spyOn(reactRedux, 'useSelector'); describe('DatabaseList', () => { useSelectorMock.mockReturnValue({ @@ -100,10 +103,27 @@ describe('DatabaseList', () => { COLUMNAR_EXTENSIONS: ['parquet', 'zip'], ALLOWED_EXTENSIONS: ['parquet', 'zip', 'xls', 'xlsx', 'csv'], }); + userSelectorMock.mockReturnValue({ + createdOn: '2021-04-27T18:12:38.952304', + email: 'admin', + firstName: 'admin', + isActive: true, + lastName: 'admin', + permissions: {}, + roles: { + Admin: [ + ['can_sqllab', 'Superset'], + ['can_write', 'Dashboard'], + ['can_write', 'Chart'], + ], + }, + userId: 1, + username: 'admin', + }); const wrapper = mount( - + , ); @@ -129,7 +149,7 @@ describe('DatabaseList', () => { it('fetches Databases', () => { const callsD = fetchMock.calls(/database\/\?q/); - expect(callsD).toHaveLength(1); + expect(callsD).toHaveLength(2); expect(callsD[0][0]).toMatchInlineSnapshot( `"http://localhost/api/v1/database/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`, ); diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx index f980295cc2035..df4ef3cf02a40 100644 --- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx +++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx @@ -17,7 +17,8 @@ * under the License. */ import { SupersetClient, t, styled } from '@superset-ui/core'; -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; +import rison from 'rison'; import { useSelector } from 'react-redux'; import Loading from 'src/components/Loading'; import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags'; @@ -28,6 +29,7 @@ import SubMenu, { SubMenuProps } from 'src/views/components/SubMenu'; import DeleteModal from 'src/components/DeleteModal'; import { Tooltip } from 'src/components/Tooltip'; import Icons from 'src/components/Icons'; +import { isUserAdmin } from 'src/dashboard/util/findPermission'; import ListView, { FilterOperator, Filters } from 'src/components/ListView'; import { commonMenuData } from 'src/views/CRUD/data/common'; import handleResourceExport from 'src/utils/export'; @@ -85,16 +87,22 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { t('database'), addDangerToast, ); + const user = useSelector( + state => state.user, + ); + const [databaseModalOpen, setDatabaseModalOpen] = useState(false); const [databaseCurrentlyDeleting, setDatabaseCurrentlyDeleting] = useState(null); const [currentDatabase, setCurrentDatabase] = useState( null, ); + const [allowUploads, setAllowUploads] = useState(false); + const isAdmin = isUserAdmin(user); + const showUploads = allowUploads || isAdmin; + const [preparingExport, setPreparingExport] = useState(false); - const { roles } = useSelector( - state => state.user, - ); + const { roles } = user; const { CSV_EXTENSIONS, COLUMNAR_EXTENSIONS, @@ -163,6 +171,8 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { ALLOWED_EXTENSIONS, ); + const isDisabled = isAdmin && !allowUploads; + const uploadDropdownMenu = [ { label: t('Upload file to database'), @@ -171,24 +181,42 @@ function DatabaseList({ addDangerToast, addSuccessToast }: DatabaseListProps) { label: t('Upload CSV'), name: 'Upload CSV file', url: '/csvtodatabaseview/form', - perm: canUploadCSV, + perm: canUploadCSV && showUploads, + disable: isDisabled, }, { label: t('Upload columnar file'), name: 'Upload columnar file', url: '/columnartodatabaseview/form', - perm: canUploadColumnar, + perm: canUploadColumnar && showUploads, + disable: isDisabled, }, { label: t('Upload Excel file'), name: 'Upload Excel file', url: '/exceltodatabaseview/form', - perm: canUploadExcel, + perm: canUploadExcel && showUploads, + disable: isDisabled, }, ], }, ]; + const hasFileUploadEnabled = () => { + const payload = { + filters: [ + { col: 'allow_file_upload', opr: 'upload_is_enabled', value: true }, + ], + }; + SupersetClient.get({ + endpoint: `/api/v1/database/?q=${rison.encode(payload)}`, + }).then(({ json }: Record) => { + setAllowUploads(json.count >= 1); + }); + }; + + useEffect(() => hasFileUploadEnabled(), [databaseModalOpen]); + const filteredDropDown = uploadDropdownMenu.map(link => { // eslint-disable-next-line no-param-reassign link.childs = link.childs.filter(item => item.perm); diff --git a/superset-frontend/src/views/components/Menu.test.tsx b/superset-frontend/src/views/components/Menu.test.tsx index d13275fbc0b57..a80a43a22f02c 100644 --- a/superset-frontend/src/views/components/Menu.test.tsx +++ b/superset-frontend/src/views/components/Menu.test.tsx @@ -18,6 +18,7 @@ */ import React from 'react'; import * as reactRedux from 'react-redux'; +import fetchMock from 'fetch-mock'; import { render, screen } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; import { Menu } from './Menu'; @@ -235,6 +236,11 @@ const notanonProps = { const useSelectorMock = jest.spyOn(reactRedux, 'useSelector'); +fetchMock.get( + 'glob:*api/v1/database/?q=(filters:!((col:allow_file_upload,opr:upload_is_enabled,value:!t)))', + {}, +); + beforeEach(() => { // setup a DOM element as a render target useSelectorMock.mockClear(); diff --git a/superset-frontend/src/views/components/Menu.tsx b/superset-frontend/src/views/components/Menu.tsx index 55d929e7b7bc4..d26742096acc7 100644 --- a/superset-frontend/src/views/components/Menu.tsx +++ b/superset-frontend/src/views/components/Menu.tsx @@ -75,6 +75,7 @@ export interface MenuObjectChildProps { isFrontendRoute?: boolean; perm?: string | boolean; view?: string; + disable?: boolean; } export interface MenuObjectProps extends MenuObjectChildProps { diff --git a/superset-frontend/src/views/components/MenuRight.tsx b/superset-frontend/src/views/components/MenuRight.tsx index 4628a47e2402b..9d657aa9b7df2 100644 --- a/superset-frontend/src/views/components/MenuRight.tsx +++ b/superset-frontend/src/views/components/MenuRight.tsx @@ -16,12 +16,20 @@ * specific language governing permissions and limitations * under the License. */ -import React, { Fragment, useState } from 'react'; +import React, { Fragment, useState, useEffect } from 'react'; +import rison from 'rison'; import { MainNav as Menu } from 'src/components/Menu'; -import { t, styled, css, SupersetTheme } from '@superset-ui/core'; +import { + t, + styled, + css, + SupersetTheme, + SupersetClient, +} from '@superset-ui/core'; +import { Tooltip } from 'src/components/Tooltip'; import { Link } from 'react-router-dom'; import Icons from 'src/components/Icons'; -import findPermission from 'src/dashboard/util/findPermission'; +import findPermission, { isUserAdmin } from 'src/dashboard/util/findPermission'; import { useSelector } from 'react-redux'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import LanguagePicker from './LanguagePicker'; @@ -45,6 +53,15 @@ const StyledI = styled.div` color: ${({ theme }) => theme.colors.primary.dark1}; `; +const styledDisabled = (theme: SupersetTheme) => css` + color: ${theme.colors.grayscale.base}; + backgroundColor: ${theme.colors.grayscale.light2}}; + .ant-menu-item:hover { + color: ${theme.colors.grayscale.base}; + cursor: default; + } +`; + const StyledDiv = styled.div<{ align: string }>` display: flex; flex-direction: row; @@ -69,9 +86,11 @@ const RightMenu = ({ navbarRight, isFrontendRoute, }: RightMenuProps) => { - const { roles } = useSelector( + const user = useSelector( state => state.user, ); + + const { roles } = user; const { CSV_EXTENSIONS, COLUMNAR_EXTENSIONS, @@ -96,6 +115,9 @@ const RightMenu = ({ const canUpload = canUploadCSV || canUploadColumnar || canUploadExcel; const showActionDropdown = canSql || canChart || canDashboard; + const [allowUploads, setAllowUploads] = useState(false); + const isAdmin = isUserAdmin(user); + const showUploads = allowUploads || isAdmin; const dropdownItems: MenuObjectProps[] = [ { label: t('Data'), @@ -115,19 +137,19 @@ const RightMenu = ({ label: t('Upload CSV to database'), name: 'Upload a CSV', url: '/csvtodatabaseview/form', - perm: canUploadCSV, + perm: CSV_EXTENSIONS && showUploads, }, { label: t('Upload columnar file to database'), name: 'Upload a Columnar file', url: '/columnartodatabaseview/form', - perm: canUploadColumnar, + perm: COLUMNAR_EXTENSIONS && showUploads, }, { label: t('Upload Excel file to database'), name: 'Upload Excel', url: '/exceltodatabaseview/form', - perm: canUploadExcel, + perm: EXCEL_EXTENSIONS && showUploads, }, ], }, @@ -154,6 +176,21 @@ const RightMenu = ({ }, ]; + const hasFileUploadEnabled = () => { + const payload = { + filters: [ + { col: 'allow_file_upload', opr: 'upload_is_enabled', value: true }, + ], + }; + SupersetClient.get({ + endpoint: `/api/v1/database/?q=${rison.encode(payload)}`, + }).then(({ json }: Record) => { + setAllowUploads(json.count >= 1); + }); + }; + + useEffect(() => hasFileUploadEnabled(), []); + const menuIconAndLabel = (menu: MenuObjectProps) => ( <> @@ -175,6 +212,34 @@ const RightMenu = ({ setShowModal(false); }; + const isDisabled = isAdmin && !allowUploads; + + const tooltipText = t( + "Enable 'Allow data upload' in any database's settings", + ); + + const buildMenuItem = (item: Record) => { + const disabledText = isDisabled && item.url; + return disabledText ? ( + + + {item.label} + + + ) : ( + + {item.url ? {item.label} : item.label} + + ); + }; + + const onMenuOpen = (openKeys: string[]) => { + if (openKeys.length) { + return hasFileUploadEnabled(); + } + return null; + }; + return ( - + {!navbarRight.user_is_anonymous && showActionDropdown && ( {idx === 2 && } - - {item.url ? ( - {item.label} - ) : ( - item.label - )} - + {buildMenuItem(item)} ) : null, )} diff --git a/superset-frontend/src/views/components/SubMenu.tsx b/superset-frontend/src/views/components/SubMenu.tsx index 4ad3cfe42ede5..9dc5b41cdcfb2 100644 --- a/superset-frontend/src/views/components/SubMenu.tsx +++ b/superset-frontend/src/views/components/SubMenu.tsx @@ -18,8 +18,9 @@ */ import React, { ReactNode, useState, useEffect } from 'react'; import { Link, useHistory } from 'react-router-dom'; -import { styled } from '@superset-ui/core'; +import { styled, SupersetTheme, css, t } from '@superset-ui/core'; import cx from 'classnames'; +import { Tooltip } from 'src/components/Tooltip'; import { debounce } from 'lodash'; import { Row } from 'src/components'; import { Menu, MenuMode, MainNav as DropdownMenu } from 'src/components/Menu'; @@ -144,6 +145,15 @@ const StyledHeader = styled.div` } `; +const styledDisabled = (theme: SupersetTheme) => css` + color: ${theme.colors.grayscale.base}; + backgroundColor: ${theme.colors.grayscale.light2}}; + .ant-menu-item:hover { + color: ${theme.colors.grayscale.base}; + cursor: default; + } +`; + type MenuChild = { label: string; name: string; @@ -271,7 +281,18 @@ const SubMenuComponent: React.FunctionComponent = props => { > {link.childs?.map(item => { if (typeof item === 'object') { - return ( + return item.disable ? ( + + + {item.label} + + + ) : ( {item.label} diff --git a/superset/databases/api.py b/superset/databases/api.py index 0de8bcf83e9ba..63fcecedc4865 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -51,7 +51,7 @@ from superset.databases.commands.validate import ValidateDatabaseParametersCommand from superset.databases.dao import DatabaseDAO from superset.databases.decorators import check_datasource_access -from superset.databases.filters import DatabaseFilter +from superset.databases.filters import DatabaseFilter, DatabaseUploadEnabledFilter from superset.databases.schemas import ( database_schemas_query_schema, DatabaseFunctionNamesResponse, @@ -166,8 +166,16 @@ class DatabaseRestApi(BaseSupersetModelRestApi): "encrypted_extra", "server_cert", ] + edit_columns = add_columns + search_columns = ["allow_file_upload", "expose_in_sqllab"] + + search_filters = { + "allow_file_upload": [DatabaseUploadEnabledFilter], + "expose_in_sqllab": [DatabaseFilter], + } + list_select_columns = list_columns + ["extra", "sqlalchemy_uri", "password"] order_columns = [ "allow_file_upload", diff --git a/superset/databases/filters.py b/superset/databases/filters.py index bd0729767ee4e..228abbc3bfa81 100644 --- a/superset/databases/filters.py +++ b/superset/databases/filters.py @@ -16,32 +16,37 @@ # under the License. from typing import Any, Set +from flask import g +from flask_babel import lazy_gettext as _ from sqlalchemy import or_ from sqlalchemy.orm import Query +from sqlalchemy.sql.expression import cast +from sqlalchemy.sql.sqltypes import JSON -from superset import security_manager +from superset import app, security_manager +from superset.models.core import Database from superset.views.base import BaseFilter -class DatabaseFilter(BaseFilter): - # TODO(bogdan): consider caching. +def can_access_databases( + view_menu_name: str, +) -> Set[str]: + return { + security_manager.unpack_database_and_schema(vm).database + for vm in security_manager.user_view_menu_names(view_menu_name) + } + - def can_access_databases( # noqa pylint: disable=no-self-use - self, - view_menu_name: str, - ) -> Set[str]: - return { - security_manager.unpack_database_and_schema(vm).database - for vm in security_manager.user_view_menu_names(view_menu_name) - } +class DatabaseFilter(BaseFilter): # pylint: disable=too-few-public-methods + # TODO(bogdan): consider caching. def apply(self, query: Query, value: Any) -> Query: if security_manager.can_access_all_databases(): return query database_perms = security_manager.user_view_menu_names("database_access") - schema_access_databases = self.can_access_databases("schema_access") + schema_access_databases = can_access_databases("schema_access") - datasource_access_databases = self.can_access_databases("datasource_access") + datasource_access_databases = can_access_databases("datasource_access") return query.filter( or_( @@ -51,3 +56,45 @@ def apply(self, query: Query, value: Any) -> Query: ), ) ) + + +class DatabaseUploadEnabledFilter(BaseFilter): # pylint: disable=too-few-public-methods + """ + Custom filter for the GET list that filters all databases based on allow_file_upload + """ + + name = _("Upload Enabled") + arg_name = "upload_is_enabled" + + def apply(self, query: Query, value: Any) -> Query: + filtered_query = query.filter(Database.allow_file_upload) + + database_perms = security_manager.user_view_menu_names("database_access") + schema_access_databases = can_access_databases("schema_access") + datasource_access_databases = can_access_databases("datasource_access") + + if hasattr(g, "user"): + allowed_schemas = [ + app.config["ALLOWED_USER_CSV_SCHEMA_FUNC"](db, g.user) + for db in datasource_access_databases + ] + + if len(allowed_schemas): + return filtered_query + + filtered_query = filtered_query.filter( + or_( + cast(Database.extra, JSON)["schemas_allowed_for_file_upload"] + is not None, + cast(Database.extra, JSON)["schemas_allowed_for_file_upload"] != [], + ) + ) + + return filtered_query.filter( + or_( + self.model.perm.in_(database_perms), + self.model.database_name.in_( + [*schema_access_databases, *datasource_access_databases] + ), + ) + ) diff --git a/tests/integration_tests/databases/api_tests.py b/tests/integration_tests/databases/api_tests.py index 4f29600bdabb7..0c1dc27538d10 100644 --- a/tests/integration_tests/databases/api_tests.py +++ b/tests/integration_tests/databases/api_tests.py @@ -80,6 +80,7 @@ def insert_database( encrypted_extra: str = "", server_cert: str = "", expose_in_sqllab: bool = False, + allow_file_upload: bool = False, ) -> Database: database = Database( database_name=database_name, @@ -88,6 +89,7 @@ def insert_database( encrypted_extra=encrypted_extra, server_cert=server_cert, expose_in_sqllab=expose_in_sqllab, + allow_file_upload=allow_file_upload, ) db.session.add(database) db.session.commit() @@ -864,6 +866,362 @@ def test_get_select_star_not_found_table(self): # TODO(bkyryliuk): investigate why presto returns 500 self.assertEqual(rv.status_code, 404 if example_db.backend != "presto" else 500) + def test_get_allow_file_upload_filter(self): + """ + Database API: Test filter for allow file upload checks for schemas + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": ["public"], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=True, + ) + db.session.commit() + yield database + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 1 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_filter_no_schema(self): + """ + Database API: Test filter for allow file upload checks for schemas. + This test has allow_file_upload but no schemas. + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": [], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=True, + ) + db.session.commit() + yield database + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_filter_allow_file_false(self): + """ + Database API: Test filter for allow file upload checks for schemas. + This has a schema but does not allow_file_upload + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": ["public"], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=False, + ) + db.session.commit() + yield database + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_false(self): + """ + Database API: Test filter for allow file upload checks for schemas. + Both databases have false allow_file_upload + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": [], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=False, + ) + db.session.commit() + yield database + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_false_no_extra(self): + """ + Database API: Test filter for allow file upload checks for schemas. + Both databases have false allow_file_upload + """ + with self.create_app().app_context(): + example_db = get_example_database() + + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + allow_file_upload=False, + ) + db.session.commit() + yield database + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def mock_csv_function(d, user): + return d.get_all_schema_names() + + @mock.patch( + "superset.views.core.app.config", + {**app.config, "ALLOWED_USER_CSV_SCHEMA_FUNC": mock_csv_function}, + ) + def test_get_allow_file_upload_true_csv(self): + """ + Database API: Test filter for allow file upload checks for schemas. + Both databases have false allow_file_upload + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": [], + } + self.login(username="admin") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=True, + ) + db.session.commit() + yield database + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 1 + db.session.delete(database) + db.session.commit() + + def mock_empty_csv_function(d, user): + return [] + + @mock.patch( + "superset.views.core.app.config", + {**app.config, "ALLOWED_USER_CSV_SCHEMA_FUNC": mock_empty_csv_function}, + ) + def test_get_allow_file_upload_false_csv(self): + """ + Database API: Test filter for allow file upload checks for schemas. + Both databases have false allow_file_upload + """ + with self.create_app().app_context(): + self.login(username="admin") + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + + def test_get_allow_file_upload_filter_no_permission(self): + """ + Database API: Test filter for allow file upload checks for schemas + """ + with self.create_app().app_context(): + example_db = get_example_database() + + extra = { + "metadata_params": {}, + "engine_params": {}, + "metadata_cache_timeout": {}, + "schemas_allowed_for_file_upload": ["public"], + } + self.login(username="gamma") + database = self.insert_database( + "database_with_upload", + example_db.sqlalchemy_uri_decrypted, + extra=json.dumps(extra), + allow_file_upload=True, + ) + db.session.commit() + yield database + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 0 + db.session.delete(database) + db.session.commit() + + def test_get_allow_file_upload_filter_with_permission(self): + """ + Database API: Test filter for allow file upload checks for schemas + """ + with self.create_app().app_context(): + main_db = get_main_database() + main_db.allow_file_upload = True + session = db.session + table = SqlaTable( + schema="public", + table_name="ab_permission", + database=get_main_database(), + ) + + session.add(table) + session.commit() + tmp_table_perm = security_manager.find_permission_view_menu( + "datasource_access", table.get_perm() + ) + gamma_role = security_manager.find_role("Gamma") + security_manager.add_permission_role(gamma_role, tmp_table_perm) + + self.login(username="gamma") + + arguments = { + "columns": ["allow_file_upload"], + "filters": [ + { + "col": "allow_file_upload", + "opr": "upload_is_enabled", + "value": True, + } + ], + } + uri = f"api/v1/database/?q={prison.dumps(arguments)}" + rv = self.client.get(uri) + data = json.loads(rv.data.decode("utf-8")) + assert data["count"] == 1 + + # rollback changes + security_manager.del_permission_role(gamma_role, tmp_table_perm) + db.session.delete(table) + db.session.delete(main_db) + db.session.commit() + def test_database_schemas(self): """ Database API: Test database schemas diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index 0b3a8b1d82d88..82b4d8717d14d 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -595,15 +595,16 @@ def test_public_sync_role_builtin_perms(self): for pvm in current_app.config["FAB_ROLES"]["TestRole"]: assert pvm in public_role_resource_names + @pytest.mark.usefixtures("load_world_bank_dashboard_with_slices") def test_sqllab_gamma_user_schema_access_to_sqllab(self): session = db.session - example_db = session.query(Database).filter_by(database_name="examples").one() example_db.expose_in_sqllab = True session.commit() arguments = { "keys": ["none"], + "columns": ["expose_in_sqllab"], "filters": [{"col": "expose_in_sqllab", "opr": "eq", "value": True}], "order_columns": "database_name", "order_direction": "asc",