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 <eschutho@gmail.com> * revisions * added to CRUD view Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
This commit is contained in:
parent
d693f4e970
commit
4bf4d58423
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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)"`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export interface MenuObjectChildProps {
|
|||
isFrontendRoute?: boolean;
|
||||
perm?: string | boolean;
|
||||
view?: string;
|
||||
disable?: boolean;
|
||||
}
|
||||
|
||||
export interface MenuObjectProps extends MenuObjectChildProps {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue