You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by aa...@apache.org on 2022/04/11 20:51:08 UTC
[superset] branch master updated: fix: update Permissions for right nav (#19051)
This is an automated email from the ASF dual-hosted git repository.
aafghahi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 4bf4d58423 fix: update Permissions for right nav (#19051)
4bf4d58423 is described below
commit 4bf4d58423e39c3cf3b592adece41049984ffced
Author: AAfghahi <48...@users.noreply.github.com>
AuthorDate: Mon Apr 11 16:50:59 2022 -0400
fix: update Permissions for right nav (#19051)
* draft pr
* finished styling
* add filter
* added testing
* added tests
* added permissions tests
* Empty-Commit
* new test
* Update superset-frontend/src/views/components/MenuRight.tsx
Co-authored-by: Elizabeth Thompson <es...@gmail.com>
* revisions
* added to CRUD view
Co-authored-by: Elizabeth Thompson <es...@gmail.com>
---
.../src/dashboard/util/findPermission.ts | 2 +-
.../views/CRUD/data/database/DatabaseList.test.jsx | 36 ++-
.../src/views/CRUD/data/database/DatabaseList.tsx | 42 ++-
.../src/views/components/Menu.test.tsx | 6 +
superset-frontend/src/views/components/Menu.tsx | 1 +
.../src/views/components/MenuRight.tsx | 94 +++++-
superset-frontend/src/views/components/SubMenu.tsx | 25 +-
superset/databases/api.py | 10 +-
superset/databases/filters.py | 73 ++++-
tests/integration_tests/databases/api_tests.py | 358 +++++++++++++++++++++
tests/integration_tests/security_tests.py | 3 +-
11 files changed, 602 insertions(+), 48 deletions(-)
diff --git a/superset-frontend/src/dashboard/util/findPermission.ts b/superset-frontend/src/dashboard/util/findPermission.ts
index 8f28a03c99..d3a8b61eca 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 fa8721e9e3..5fe6ead7fd 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(
<Provider store={store}>
- <DatabaseList user={mockUser} />
+ <DatabaseList />
</Provider>,
);
@@ -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 f980295cc2..df4ef3cf02 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<any, UserWithPermissionsAndRoles>(
+ state => state.user,
+ );
+
const [databaseModalOpen, setDatabaseModalOpen] = useState<boolean>(false);
const [databaseCurrentlyDeleting, setDatabaseCurrentlyDeleting] =
useState<DatabaseDeleteObject | null>(null);
const [currentDatabase, setCurrentDatabase] = useState<DatabaseObject | null>(
null,
);
+ const [allowUploads, setAllowUploads] = useState<boolean>(false);
+ const isAdmin = isUserAdmin(user);
+ const showUploads = allowUploads || isAdmin;
+
const [preparingExport, setPreparingExport] = useState<boolean>(false);
- const { roles } = useSelector<any, UserWithPermissionsAndRoles>(
- 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<string, any>) => {
+ 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 d13275fbc0..a80a43a22f 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 55d929e7b7..d26742096a 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 4628a47e24..9d657aa9b7 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<any, UserWithPermissionsAndRoles>(
+ const user = useSelector<any, UserWithPermissionsAndRoles>(
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<boolean>(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<string, any>) => {
+ setAllowUploads(json.count >= 1);
+ });
+ };
+
+ useEffect(() => hasFileUploadEnabled(), []);
+
const menuIconAndLabel = (menu: MenuObjectProps) => (
<>
<i data-test={`menu-item-${menu.label}`} className={`fa ${menu.icon}`} />
@@ -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<string, any>) => {
+ const disabledText = isDisabled && item.url;
+ return disabledText ? (
+ <Menu.Item key={item.name} css={styledDisabled}>
+ <Tooltip placement="top" title={tooltipText}>
+ {item.label}
+ </Tooltip>
+ </Menu.Item>
+ ) : (
+ <Menu.Item key={item.name}>
+ {item.url ? <a href={item.url}> {item.label} </a> : item.label}
+ </Menu.Item>
+ );
+ };
+
+ const onMenuOpen = (openKeys: string[]) => {
+ if (openKeys.length) {
+ return hasFileUploadEnabled();
+ }
+ return null;
+ };
+
return (
<StyledDiv align={align}>
<DatabaseModal
@@ -182,7 +247,12 @@ const RightMenu = ({
show={showModal}
dbEngine={engine}
/>
- <Menu selectable={false} mode="horizontal" onClick={handleMenuSelection}>
+ <Menu
+ selectable={false}
+ mode="horizontal"
+ onClick={handleMenuSelection}
+ onOpenChange={onMenuOpen}
+ >
{!navbarRight.user_is_anonymous && showActionDropdown && (
<SubMenu
data-test="new-dropdown"
@@ -203,13 +273,7 @@ const RightMenu = ({
typeof item !== 'string' && item.name && item.perm ? (
<Fragment key={item.name}>
{idx === 2 && <Menu.Divider />}
- <Menu.Item key={item.name}>
- {item.url ? (
- <a href={item.url}> {item.label} </a>
- ) : (
- item.label
- )}
- </Menu.Item>
+ {buildMenuItem(item)}
</Fragment>
) : null,
)}
diff --git a/superset-frontend/src/views/components/SubMenu.tsx b/superset-frontend/src/views/components/SubMenu.tsx
index 4ad3cfe42e..9dc5b41cdc 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<SubMenuProps> = props => {
>
{link.childs?.map(item => {
if (typeof item === 'object') {
- return (
+ return item.disable ? (
+ <DropdownMenu.Item key={item.label} css={styledDisabled}>
+ <Tooltip
+ placement="top"
+ title={t(
+ "Enable 'Allow data upload' in any database's settings",
+ )}
+ >
+ {item.label}
+ </Tooltip>
+ </DropdownMenu.Item>
+ ) : (
<DropdownMenu.Item key={item.label}>
<a href={item.url}>{item.label}</a>
</DropdownMenu.Item>
diff --git a/superset/databases/api.py b/superset/databases/api.py
index 0de8bcf83e..63fcecedc4 100644
--- a/superset/databases/api.py
+++ b/superset/databases/api.py
@@ -51,7 +51,7 @@ from superset.databases.commands.update import UpdateDatabaseCommand
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 bd0729767e..228abbc3bf 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 @@ class DatabaseFilter(BaseFilter):
),
)
)
+
+
+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 4f29600bda..0c1dc27538 100644
--- a/tests/integration_tests/databases/api_tests.py
+++ b/tests/integration_tests/databases/api_tests.py
@@ -80,6 +80,7 @@ class TestDatabaseApi(SupersetTestCase):
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 @@ class TestDatabaseApi(SupersetTestCase):
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 @@ class TestDatabaseApi(SupersetTestCase):
# 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 0b3a8b1d82..82b4d8717d 100644
--- a/tests/integration_tests/security_tests.py
+++ b/tests/integration_tests/security_tests.py
@@ -595,15 +595,16 @@ class TestRolePermission(SupersetTestCase):
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",