mirror of https://github.com/apache/superset.git
feat(revert): Re-introduces the RLS page (#23777)
This commit is contained in:
parent
c536d92ade
commit
f7810b6020
|
@ -53,6 +53,7 @@ assists people when migrating to a new version.
|
|||
- [22798](https://github.com/apache/superset/pull/22798): To make the welcome page more relevant in production environments, the last tab on the welcome page has been changed from to feature all charts/dashboards the user has access to (previously only examples were shown). To keep current behavior unchanged, add the following to your `superset_config.py`: `WELCOME_PAGE_LAST_TAB = "examples"`
|
||||
- [22328](https://github.com/apache/superset/pull/22328): For deployments that have enabled the "THUMBNAILS" feature flag, the function that calculates dashboard digests has been updated to consider additional properties to more accurately identify changes in the dashboard metadata. This change will invalidate all currently cached dashboard thumbnails.
|
||||
- [21765](https://github.com/apache/superset/pull/21765): For deployments that have enabled the "ALERT_REPORTS" feature flag, Gamma users will no longer have read and write access to Alerts & Reports by default. To give Gamma users the ability to schedule reports from the Dashboard and Explore view like before, create an additional role with "can read on ReportSchedule" and "can write on ReportSchedule" permissions. To further give Gamma users access to the "Alerts & Reports" menu and CRUD view, add "menu access on Manage" and "menu access on Alerts & Report" permissions to the role.
|
||||
- [22325](https://github.com/apache/superset/pull/22325): "RLS_FORM_QUERY_REL_FIELDS" is replaced by "RLS_BASE_RELATED_FIELD_FILTERS" feature flag. Its value format stays same.
|
||||
|
||||
### Potential Downtime
|
||||
|
||||
|
|
|
@ -0,0 +1,295 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { render, screen, waitFor, within } from 'spec/helpers/testing-library';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import RowLevelSecurityModal, {
|
||||
RowLevelSecurityModalProps,
|
||||
} from './RowLevelSecurityModal';
|
||||
import { FilterType } from './types';
|
||||
|
||||
const getRuleEndpoint = 'glob:*/api/v1/rowlevelsecurity/1';
|
||||
const getRelatedRolesEndpoint =
|
||||
'glob:*/api/v1/rowlevelsecurity/related/roles?q*';
|
||||
const getRelatedTablesEndpoint =
|
||||
'glob:*/api/v1/rowlevelsecurity/related/tables?q*';
|
||||
const postRuleEndpoint = 'glob:*/api/v1/rowlevelsecurity/*';
|
||||
const putRuleEndpoint = 'glob:*/api/v1/rowlevelsecurity/1';
|
||||
|
||||
const mockGetRuleResult = {
|
||||
description_columns: {},
|
||||
id: 1,
|
||||
label_columns: {
|
||||
clause: 'Clause',
|
||||
description: 'Description',
|
||||
filter_type: 'Filter Type',
|
||||
group_key: 'Group Key',
|
||||
name: 'Name',
|
||||
'roles.id': 'Roles Id',
|
||||
'roles.name': 'Roles Name',
|
||||
'tables.id': 'Tables Id',
|
||||
'tables.table_name': 'Tables Table Name',
|
||||
},
|
||||
result: {
|
||||
clause: 'gender="girl"',
|
||||
description: 'test rls rule with RTL',
|
||||
filter_type: 'Base',
|
||||
group_key: 'g1',
|
||||
id: 1,
|
||||
name: 'rls 1',
|
||||
roles: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Admin',
|
||||
},
|
||||
],
|
||||
tables: [
|
||||
{
|
||||
id: 2,
|
||||
table_name: 'birth_names',
|
||||
},
|
||||
],
|
||||
},
|
||||
show_columns: [
|
||||
'name',
|
||||
'description',
|
||||
'filter_type',
|
||||
'tables.id',
|
||||
'tables.table_name',
|
||||
'roles.id',
|
||||
'roles.name',
|
||||
'group_key',
|
||||
'clause',
|
||||
],
|
||||
show_title: 'Show Row Level Security Filter',
|
||||
};
|
||||
|
||||
const mockGetRolesResult = {
|
||||
count: 3,
|
||||
result: [
|
||||
{
|
||||
extra: {},
|
||||
text: 'Admin',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
extra: {},
|
||||
text: 'Public',
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
extra: {},
|
||||
text: 'Alpha',
|
||||
value: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockGetTablesResult = {
|
||||
count: 3,
|
||||
result: [
|
||||
{
|
||||
extra: {},
|
||||
text: 'wb_health_population',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
extra: {},
|
||||
text: 'birth_names',
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
extra: {},
|
||||
text: 'long_lat',
|
||||
value: 3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
fetchMock.get(getRuleEndpoint, mockGetRuleResult);
|
||||
fetchMock.get(getRelatedRolesEndpoint, mockGetRolesResult);
|
||||
fetchMock.get(getRelatedTablesEndpoint, mockGetTablesResult);
|
||||
fetchMock.post(postRuleEndpoint, {});
|
||||
fetchMock.put(putRuleEndpoint, {});
|
||||
|
||||
global.URL.createObjectURL = jest.fn();
|
||||
|
||||
const NOOP = () => {};
|
||||
|
||||
const addNewRuleDefaultProps: RowLevelSecurityModalProps = {
|
||||
addDangerToast: NOOP,
|
||||
addSuccessToast: NOOP,
|
||||
show: true,
|
||||
rule: null,
|
||||
onHide: NOOP,
|
||||
};
|
||||
|
||||
describe('Rule modal', () => {
|
||||
async function renderAndWait(props: RowLevelSecurityModalProps) {
|
||||
const mounted = act(async () => {
|
||||
render(<RowLevelSecurityModal {...props} />, { useRedux: true });
|
||||
});
|
||||
return mounted;
|
||||
}
|
||||
|
||||
it('Sets correct title for adding new rule', async () => {
|
||||
await renderAndWait(addNewRuleDefaultProps);
|
||||
const title = screen.getByText('Add Rule');
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(fetchMock.calls(getRuleEndpoint)).toHaveLength(0);
|
||||
expect(fetchMock.calls(getRelatedTablesEndpoint)).toHaveLength(0);
|
||||
expect(fetchMock.calls(getRelatedRolesEndpoint)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Sets correct title for editing existing rule', async () => {
|
||||
await renderAndWait({
|
||||
...addNewRuleDefaultProps,
|
||||
rule: {
|
||||
id: 1,
|
||||
name: 'test rule',
|
||||
filter_type: FilterType.BASE,
|
||||
tables: [{ key: 1, id: 1, value: 'birth_names' }],
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
const title = screen.getByText('Edit Rule');
|
||||
expect(title).toBeInTheDocument();
|
||||
expect(fetchMock.calls(getRuleEndpoint)).toHaveLength(1);
|
||||
expect(fetchMock.calls(getRelatedTablesEndpoint)).toHaveLength(0);
|
||||
expect(fetchMock.calls(getRelatedRolesEndpoint)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Fills correct values when editing rule', async () => {
|
||||
await renderAndWait({
|
||||
...addNewRuleDefaultProps,
|
||||
rule: {
|
||||
id: 1,
|
||||
name: 'rls 1',
|
||||
filter_type: FilterType.BASE,
|
||||
},
|
||||
});
|
||||
|
||||
const name = await screen.findByTestId('rule-name-test');
|
||||
expect(name).toHaveDisplayValue('rls 1');
|
||||
userEvent.type(name, 'rls 2');
|
||||
expect(name).toHaveDisplayValue('rls 2');
|
||||
|
||||
const filterType = await screen.findByText('Base');
|
||||
expect(filterType).toBeInTheDocument();
|
||||
|
||||
const roles = await screen.findByText('Admin');
|
||||
expect(roles).toBeInTheDocument();
|
||||
|
||||
const tables = await screen.findByText('birth_names');
|
||||
expect(tables).toBeInTheDocument();
|
||||
|
||||
const groupKey = await screen.findByTestId('group-key-test');
|
||||
expect(groupKey).toHaveValue('g1');
|
||||
userEvent.clear(groupKey);
|
||||
userEvent.type(groupKey, 'g2');
|
||||
expect(groupKey).toHaveValue('g2');
|
||||
|
||||
const clause = await screen.findByTestId('clause-test');
|
||||
expect(clause).toHaveValue('gender="girl"');
|
||||
userEvent.clear(clause);
|
||||
userEvent.type(clause, 'gender="boy"');
|
||||
expect(clause).toHaveValue('gender="boy"');
|
||||
|
||||
const description = await screen.findByTestId('description-test');
|
||||
expect(description).toHaveValue('test rls rule with RTL');
|
||||
userEvent.clear(description);
|
||||
userEvent.type(description, 'test description');
|
||||
expect(description).toHaveValue('test description');
|
||||
});
|
||||
|
||||
it('Does not allow to create rule without name, tables and clause', async () => {
|
||||
await renderAndWait(addNewRuleDefaultProps);
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /add/i });
|
||||
expect(addButton).toBeDisabled();
|
||||
|
||||
const nameTextBox = screen.getByTestId('rule-name-test');
|
||||
userEvent.type(nameTextBox, 'name');
|
||||
|
||||
expect(addButton).toBeDisabled();
|
||||
|
||||
const getSelect = () => screen.getByRole('combobox', { name: 'Tables' });
|
||||
const getElementByClassName = (className: string) =>
|
||||
document.querySelector(className)! as HTMLElement;
|
||||
|
||||
const findSelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).getByText(text),
|
||||
);
|
||||
const open = () => waitFor(() => userEvent.click(getSelect()));
|
||||
await open();
|
||||
userEvent.click(await findSelectOption('birth_names'));
|
||||
expect(addButton).toBeDisabled();
|
||||
|
||||
const clause = await screen.findByTestId('clause-test');
|
||||
userEvent.type(clause, 'gender="girl"');
|
||||
|
||||
expect(addButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('Creates a new rule', async () => {
|
||||
await renderAndWait(addNewRuleDefaultProps);
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /add/i });
|
||||
|
||||
const nameTextBox = screen.getByTestId('rule-name-test');
|
||||
userEvent.type(nameTextBox, 'name');
|
||||
|
||||
const getSelect = () => screen.getByRole('combobox', { name: 'Tables' });
|
||||
const getElementByClassName = (className: string) =>
|
||||
document.querySelector(className)! as HTMLElement;
|
||||
|
||||
const findSelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).getByText(text),
|
||||
);
|
||||
const open = () => waitFor(() => userEvent.click(getSelect()));
|
||||
await open();
|
||||
userEvent.click(await findSelectOption('birth_names'));
|
||||
|
||||
const clause = await screen.findByTestId('clause-test');
|
||||
userEvent.type(clause, 'gender="girl"');
|
||||
|
||||
await waitFor(() => userEvent.click(addButton));
|
||||
|
||||
expect(fetchMock.calls(postRuleEndpoint)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Updates existing rule', async () => {
|
||||
await renderAndWait({
|
||||
...addNewRuleDefaultProps,
|
||||
rule: {
|
||||
id: 1,
|
||||
name: 'rls 1',
|
||||
filter_type: FilterType.BASE,
|
||||
},
|
||||
});
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /save/i });
|
||||
await waitFor(() => userEvent.click(addButton));
|
||||
expect(fetchMock.calls(putRuleEndpoint)).toHaveLength(4);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,479 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
css,
|
||||
styled,
|
||||
SupersetClient,
|
||||
SupersetTheme,
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import Modal from 'src/components/Modal';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Icons from 'src/components/Icons';
|
||||
import Select from 'src/components/Select/Select';
|
||||
import AsyncSelect from 'src/components/Select/AsyncSelect';
|
||||
import rison from 'rison';
|
||||
import { LabeledErrorBoundInput } from 'src/components/Form';
|
||||
import { noBottomMargin } from 'src/components/ReportModal/styles';
|
||||
import InfoTooltip from 'src/components/InfoTooltip';
|
||||
import { useSingleViewResource } from 'src/views/CRUD/hooks';
|
||||
import { FilterOptions } from './constants';
|
||||
import { FilterType, RLSObject, RoleObject, TableObject } from './types';
|
||||
|
||||
const StyledModal = styled(Modal)`
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
.ant-modal-body {
|
||||
overflow: initial;
|
||||
}
|
||||
`;
|
||||
const StyledIcon = (theme: SupersetTheme) => css`
|
||||
margin: auto ${theme.gridUnit * 2}px auto 0;
|
||||
color: ${theme.colors.grayscale.base};
|
||||
`;
|
||||
|
||||
const StyledSectionContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: ${({ theme }) =>
|
||||
`${theme.gridUnit * 3}px ${theme.gridUnit * 4}px ${theme.gridUnit * 2}px`};
|
||||
|
||||
label {
|
||||
font-size: ${({ theme }) => theme.typography.sizes.s}px;
|
||||
color: ${({ theme }) => theme.colors.grayscale.light1};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledInputContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: ${({ theme }) => theme.gridUnit}px;
|
||||
|
||||
margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
margin-right: ${({ theme }) => theme.gridUnit * 2}px;
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 100px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.required {
|
||||
margin-left: ${({ theme }) => theme.gridUnit / 2}px;
|
||||
color: ${({ theme }) => theme.colors.error.base};
|
||||
}
|
||||
`;
|
||||
|
||||
export interface RowLevelSecurityModalProps {
|
||||
rule: RLSObject | null;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
onAdd?: (alert?: any) => void;
|
||||
onHide: () => void;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
const DEAFULT_RULE = {
|
||||
name: '',
|
||||
filter_type: FilterType.REGULAR,
|
||||
tables: [],
|
||||
roles: [],
|
||||
clause: '',
|
||||
group_key: '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
function RowLevelSecurityModal(props: RowLevelSecurityModalProps) {
|
||||
const { rule, addDangerToast, addSuccessToast, onHide, show } = props;
|
||||
|
||||
const [currentRule, setCurrentRule] = useState<RLSObject>({
|
||||
...DEAFULT_RULE,
|
||||
});
|
||||
const [disableSave, setDisableSave] = useState<boolean>(true);
|
||||
|
||||
const isEditMode = rule !== null;
|
||||
|
||||
// * hooks *
|
||||
const {
|
||||
state: { loading, resource, error: fetchError },
|
||||
fetchResource,
|
||||
createResource,
|
||||
updateResource,
|
||||
clearError,
|
||||
} = useSingleViewResource<RLSObject>(
|
||||
`rowlevelsecurity`,
|
||||
t('rowlevelsecurity'),
|
||||
addDangerToast,
|
||||
);
|
||||
|
||||
// initialize
|
||||
useEffect(() => {
|
||||
if (!isEditMode) {
|
||||
setCurrentRule({ ...DEAFULT_RULE });
|
||||
} else if (rule?.id !== null && !loading && !fetchError) {
|
||||
fetchResource(rule.id as number);
|
||||
}
|
||||
}, [rule]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resource) {
|
||||
setCurrentRule({ ...resource, id: rule?.id });
|
||||
const selectedTableAndRoles = getSelectedData();
|
||||
updateRuleState('tables', selectedTableAndRoles?.tables || []);
|
||||
updateRuleState('roles', selectedTableAndRoles?.roles || []);
|
||||
}
|
||||
}, [resource]);
|
||||
|
||||
// find selected tables and roles
|
||||
const getSelectedData = useCallback(() => {
|
||||
if (!resource) {
|
||||
return null;
|
||||
}
|
||||
const tables: TableObject[] = [];
|
||||
const roles: RoleObject[] = [];
|
||||
|
||||
resource.tables?.forEach(selectedTable => {
|
||||
tables.push({
|
||||
key: selectedTable.id,
|
||||
label: selectedTable.schema
|
||||
? `${selectedTable.schema}.${selectedTable.table_name}`
|
||||
: selectedTable.table_name,
|
||||
value: selectedTable.id,
|
||||
});
|
||||
});
|
||||
|
||||
resource.roles?.forEach(selectedRole => {
|
||||
roles.push({
|
||||
key: selectedRole.id,
|
||||
label: selectedRole.name,
|
||||
value: selectedRole.id,
|
||||
});
|
||||
});
|
||||
|
||||
return { tables, roles };
|
||||
}, [resource?.tables, resource?.roles]);
|
||||
|
||||
// validate
|
||||
const currentRuleSafe = currentRule || {};
|
||||
useEffect(() => {
|
||||
validate();
|
||||
}, [currentRuleSafe.name, currentRuleSafe.clause, currentRuleSafe?.tables]);
|
||||
|
||||
// * event handlers *
|
||||
type SelectValue = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const updateRuleState = (name: string, value: any) => {
|
||||
setCurrentRule(currentRuleData => ({
|
||||
...currentRuleData,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const onTextChange = (target: HTMLInputElement | HTMLTextAreaElement) => {
|
||||
updateRuleState(target.name, target.value);
|
||||
};
|
||||
|
||||
const onFilterChange = (type: string) => {
|
||||
updateRuleState('filter_type', type);
|
||||
};
|
||||
|
||||
const onTablesChange = (tables: Array<SelectValue>) => {
|
||||
updateRuleState('tables', tables || []);
|
||||
};
|
||||
|
||||
const onRolesChange = (roles: Array<SelectValue>) => {
|
||||
updateRuleState('roles', roles || []);
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
clearError();
|
||||
setCurrentRule({ ...DEAFULT_RULE });
|
||||
onHide();
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
const tables: number[] = [];
|
||||
const roles: number[] = [];
|
||||
|
||||
currentRule.tables?.forEach(table => tables.push(table.key));
|
||||
currentRule.roles?.forEach(role => roles.push(role.key));
|
||||
|
||||
const data: any = { ...currentRule, tables, roles };
|
||||
|
||||
if (isEditMode && currentRule.id) {
|
||||
const updateId = currentRule.id;
|
||||
delete data.id;
|
||||
updateResource(updateId, data).then(response => {
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
addSuccessToast(`Rule updated`);
|
||||
hide();
|
||||
});
|
||||
} else if (currentRule) {
|
||||
createResource(data).then(response => {
|
||||
if (!response) return;
|
||||
addSuccessToast(t('Rule added'));
|
||||
hide();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// * data loaders *
|
||||
const loadTableOptions = useMemo(
|
||||
() =>
|
||||
(input = '', page: number, pageSize: number) => {
|
||||
const query = rison.encode({
|
||||
filter: input,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/rowlevelsecurity/related/tables?q=${query}`,
|
||||
}).then(response => {
|
||||
const list = response.json.result.map(
|
||||
(item: { value: number; text: string }) => ({
|
||||
label: item.text,
|
||||
value: item.value,
|
||||
}),
|
||||
);
|
||||
return { data: list, totalCount: response.json.count };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadRoleOptions = useMemo(
|
||||
() =>
|
||||
(input = '', page: number, pageSize: number) => {
|
||||
const query = rison.encode({
|
||||
filter: input,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
return SupersetClient.get({
|
||||
endpoint: `/api/v1/rowlevelsecurity/related/roles?q=${query}`,
|
||||
}).then(response => {
|
||||
const list = response.json.result.map(
|
||||
(item: { value: number; text: string }) => ({
|
||||
label: item.text,
|
||||
value: item.value,
|
||||
}),
|
||||
);
|
||||
return { data: list, totalCount: response.json.count };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// * state validators *
|
||||
const validate = () => {
|
||||
if (
|
||||
currentRule?.name &&
|
||||
currentRule?.clause &&
|
||||
currentRule.tables?.length
|
||||
) {
|
||||
setDisableSave(false);
|
||||
} else {
|
||||
setDisableSave(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
className="no-content-padding"
|
||||
responsive
|
||||
show={show}
|
||||
onHide={hide}
|
||||
primaryButtonName={isEditMode ? t('Save') : t('Add')}
|
||||
disablePrimaryButton={disableSave}
|
||||
onHandledPrimaryAction={onSave}
|
||||
width="30%"
|
||||
maxWidth="1450px"
|
||||
title={
|
||||
<h4 data-test="rls-modal-title">
|
||||
{isEditMode ? (
|
||||
<Icons.EditAlt css={StyledIcon} />
|
||||
) : (
|
||||
<Icons.PlusLarge css={StyledIcon} />
|
||||
)}
|
||||
{isEditMode ? t('Edit Rule') : t('Add Rule')}
|
||||
</h4>
|
||||
}
|
||||
>
|
||||
<StyledSectionContainer>
|
||||
<div className="main-section">
|
||||
<StyledInputContainer>
|
||||
<LabeledErrorBoundInput
|
||||
id="name"
|
||||
name="name"
|
||||
className="labeled-input"
|
||||
value={currentRule ? currentRule.name : ''}
|
||||
required
|
||||
validationMethods={{
|
||||
onChange: ({ target }: { target: HTMLInputElement }) =>
|
||||
onTextChange(target),
|
||||
}}
|
||||
css={noBottomMargin}
|
||||
label={t('Rule Name')}
|
||||
data-test="rule-name-test"
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
|
||||
<StyledInputContainer>
|
||||
<div className="control-label">
|
||||
{t('Filter Type')}{' '}
|
||||
<InfoTooltip
|
||||
tooltip={t(
|
||||
'Regular filters add where clauses to queries if a user belongs to a role referenced in the filter, base filters apply filters to all queries except the roles defined in the filter, and can be used to define what users can see if no RLS filters within a filter group apply to them.',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-container">
|
||||
<Select
|
||||
name="filter_type"
|
||||
ariaLabel={t('Filter Type')}
|
||||
placeholder={t('Filter Type')}
|
||||
onChange={onFilterChange}
|
||||
value={currentRule?.filter_type}
|
||||
options={FilterOptions}
|
||||
data-test="rule-filter-type-test"
|
||||
/>
|
||||
</div>
|
||||
</StyledInputContainer>
|
||||
|
||||
<StyledInputContainer>
|
||||
<div className="control-label">
|
||||
{t('Tables')} <span className="required">*</span>
|
||||
<InfoTooltip
|
||||
tooltip={t(
|
||||
'These are the tables this filter will be applied to.',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-container">
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Tables')}
|
||||
mode="multiple"
|
||||
onChange={onTablesChange}
|
||||
value={(currentRule?.tables as SelectValue[]) || []}
|
||||
options={loadTableOptions}
|
||||
/>
|
||||
</div>
|
||||
</StyledInputContainer>
|
||||
|
||||
<StyledInputContainer>
|
||||
<div className="control-label">
|
||||
{t('Roles')}{' '}
|
||||
<InfoTooltip
|
||||
tooltip={t(
|
||||
'For regular filters, these are the roles this filter will be applied to. For base filters, these are the roles that the filter DOES NOT apply to, e.g. Admin if admin should see all data.',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-container">
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Roles')}
|
||||
mode="multiple"
|
||||
onChange={onRolesChange}
|
||||
value={(currentRule?.roles as SelectValue[]) || []}
|
||||
options={loadRoleOptions}
|
||||
/>
|
||||
</div>
|
||||
</StyledInputContainer>
|
||||
<StyledInputContainer>
|
||||
<LabeledErrorBoundInput
|
||||
id="group_key"
|
||||
name="group_key"
|
||||
value={currentRule ? currentRule.group_key : ''}
|
||||
validationMethods={{
|
||||
onChange: ({ target }: { target: HTMLInputElement }) =>
|
||||
onTextChange(target),
|
||||
}}
|
||||
css={noBottomMargin}
|
||||
label={t('Group Key')}
|
||||
hasTooltip
|
||||
tooltipText={t(
|
||||
`Filters with the same group key will be ORed together within the group, while different filter groups will be ANDed together. Undefined group keys are treated as unique groups, i.e. are not grouped together. For example, if a table has three filters, of which two are for departments Finance and Marketing (group key = 'department'), and one refers to the region Europe (group key = 'region'), the filter clause would apply the filter (department = 'Finance' OR department = 'Marketing') AND (region = 'Europe').`,
|
||||
)}
|
||||
data-test="group-key-test"
|
||||
/>
|
||||
</StyledInputContainer>
|
||||
|
||||
<StyledInputContainer>
|
||||
<div className="control-label">
|
||||
<LabeledErrorBoundInput
|
||||
id="clause"
|
||||
name="clause"
|
||||
value={currentRule ? currentRule.clause : ''}
|
||||
required
|
||||
validationMethods={{
|
||||
onChange: ({ target }: { target: HTMLInputElement }) =>
|
||||
onTextChange(target),
|
||||
}}
|
||||
css={noBottomMargin}
|
||||
label={t('Clause')}
|
||||
hasTooltip
|
||||
tooltipText={t(
|
||||
'This is the condition that will be added to the WHERE clause. For example, to only return rows for a particular client, you might define a regular filter with the clause `client_id = 9`. To display no rows unless a user belongs to a RLS filter role, a base filter can be created with the clause `1 = 0` (always false).',
|
||||
)}
|
||||
data-test="clause-test"
|
||||
/>
|
||||
</div>
|
||||
</StyledInputContainer>
|
||||
|
||||
<StyledInputContainer>
|
||||
<div className="control-label">{t('Description')}</div>
|
||||
<div className="input-container">
|
||||
<textarea
|
||||
name="description"
|
||||
value={currentRule ? currentRule.description : ''}
|
||||
onChange={event => onTextChange(event.target)}
|
||||
data-test="description-test"
|
||||
/>
|
||||
</div>
|
||||
</StyledInputContainer>
|
||||
</div>
|
||||
</StyledSectionContainer>
|
||||
</StyledModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RowLevelSecurityModal;
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { t } from '@superset-ui/core';
|
||||
|
||||
export const FilterOptions = [
|
||||
{
|
||||
label: t('Regular'),
|
||||
value: 'Regular',
|
||||
},
|
||||
{
|
||||
label: t('Base'),
|
||||
value: 'Base',
|
||||
},
|
||||
];
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export enum FilterType {
|
||||
REGULAR = 'Regular',
|
||||
BASE = 'Base',
|
||||
}
|
||||
|
||||
export type RLSObject = {
|
||||
id?: number;
|
||||
name: string;
|
||||
filter_type: FilterType;
|
||||
tables?: TableObject[];
|
||||
roles?: RoleObject[];
|
||||
group_key?: string;
|
||||
clause?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type TableObject = {
|
||||
key: any;
|
||||
id?: number;
|
||||
label?: string;
|
||||
value?: number | string;
|
||||
schema?: string;
|
||||
table_name?: string;
|
||||
};
|
||||
|
||||
export type RoleObject = {
|
||||
key: any;
|
||||
id?: number;
|
||||
label?: string;
|
||||
value?: number | string;
|
||||
name?: string;
|
||||
};
|
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { render, screen, within } from 'spec/helpers/testing-library';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import { styledMount as mount } from 'spec/helpers/theming';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
||||
import ListView from 'src/components/ListView/ListView';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import RowLevelSecurityList from '.';
|
||||
|
||||
const ruleListEndpoint = 'glob:*/api/v1/rowlevelsecurity/?*';
|
||||
const ruleInfoEndpoint = 'glob:*/api/v1/rowlevelsecurity/_info*';
|
||||
|
||||
const mockRules = [
|
||||
{
|
||||
changed_on_delta_humanized: '1 days ago',
|
||||
clause: '1=1',
|
||||
description: 'some description',
|
||||
filter_type: 'Regular',
|
||||
group_key: 'group-1',
|
||||
id: 1,
|
||||
name: 'rule 1',
|
||||
roles: [
|
||||
{
|
||||
id: 3,
|
||||
name: 'Alpha',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'granter',
|
||||
},
|
||||
],
|
||||
tables: [
|
||||
{
|
||||
id: 6,
|
||||
table_name: 'flights',
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
table_name: 'messages',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
changed_on_delta_humanized: '2 days ago',
|
||||
clause: '2=2',
|
||||
description: 'some description 2',
|
||||
filter_type: 'Base',
|
||||
group_key: 'group-1',
|
||||
id: 2,
|
||||
name: 'rule 2',
|
||||
roles: [
|
||||
{
|
||||
id: 3,
|
||||
name: 'Alpha',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'granter',
|
||||
},
|
||||
],
|
||||
tables: [
|
||||
{
|
||||
id: 6,
|
||||
table_name: 'flights',
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
table_name: 'messages',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
fetchMock.get(ruleListEndpoint, { result: mockRules, count: 2 });
|
||||
fetchMock.get(ruleInfoEndpoint, { permissions: ['can_read', 'can_write'] });
|
||||
global.URL.createObjectURL = jest.fn();
|
||||
|
||||
const mockUser = {
|
||||
userId: 1,
|
||||
};
|
||||
|
||||
const mockedProps = {};
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({});
|
||||
|
||||
describe('RulesList Enzyme', () => {
|
||||
let wrapper: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
fetchMock.resetHistory();
|
||||
wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<Provider store={store}>
|
||||
<RowLevelSecurityList {...mockedProps} user={mockUser} />
|
||||
</Provider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitForComponentToPaint(wrapper);
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
expect(wrapper.find(RowLevelSecurityList)).toExist();
|
||||
});
|
||||
it('renders a ListView', () => {
|
||||
expect(wrapper.find(ListView)).toExist();
|
||||
});
|
||||
it('fetched data', () => {
|
||||
// wrapper.update();
|
||||
const apiCalls = fetchMock.calls(/rowlevelsecurity\/\?q/);
|
||||
expect(apiCalls).toHaveLength(1);
|
||||
expect(apiCalls[0][0]).toMatchInlineSnapshot(
|
||||
`"http://localhost/api/v1/rowlevelsecurity/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleList RTL', () => {
|
||||
async function renderAndWait() {
|
||||
const mounted = act(async () => {
|
||||
const mockedProps = {};
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider>
|
||||
<RowLevelSecurityList {...mockedProps} user={mockUser} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
});
|
||||
return mounted;
|
||||
}
|
||||
|
||||
it('renders add rule button on empty state', async () => {
|
||||
fetchMock.get(
|
||||
ruleListEndpoint,
|
||||
{ result: [], count: 0 },
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
await renderAndWait();
|
||||
|
||||
const emptyAddRuleButton = await screen.findByTestId('add-rule-empty');
|
||||
expect(emptyAddRuleButton).toBeInTheDocument();
|
||||
fetchMock.get(
|
||||
ruleListEndpoint,
|
||||
{ result: mockRules, count: 2 },
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a "Rule" button to add a rule in bulk action', async () => {
|
||||
await renderAndWait();
|
||||
|
||||
const addRuleButton = await screen.findByTestId('add-rule');
|
||||
const emptyAddRuleButton = screen.queryByTestId('add-rule-empty');
|
||||
expect(addRuleButton).toBeInTheDocument();
|
||||
expect(emptyAddRuleButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders filter options', async () => {
|
||||
await renderAndWait();
|
||||
|
||||
const searchFilters = screen.queryAllByTestId('filters-search');
|
||||
expect(searchFilters).toHaveLength(2);
|
||||
|
||||
const typeFilter = await screen.findByTestId('filters-select');
|
||||
expect(typeFilter).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct list columns', async () => {
|
||||
await renderAndWait();
|
||||
|
||||
const table = screen.getByRole('table');
|
||||
expect(table).toBeInTheDocument();
|
||||
|
||||
const nameColumn = await within(table).findByText('Name');
|
||||
const fitlerTypeColumn = await within(table).findByText('Filter Type');
|
||||
const groupKeyColumn = await within(table).findByText('Group Key');
|
||||
const clauseColumn = await within(table).findByText('Clause');
|
||||
const modifiedColumn = await within(table).findByText('Modified');
|
||||
const actionsColumn = await within(table).findByText('Actions');
|
||||
|
||||
expect(nameColumn).toBeInTheDocument();
|
||||
expect(fitlerTypeColumn).toBeInTheDocument();
|
||||
expect(groupKeyColumn).toBeInTheDocument();
|
||||
expect(clauseColumn).toBeInTheDocument();
|
||||
expect(modifiedColumn).toBeInTheDocument();
|
||||
expect(actionsColumn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correct action buttons with write permission', async () => {
|
||||
await renderAndWait();
|
||||
|
||||
const deleteActionIcon = screen.queryAllByTestId('rls-list-trash-icon');
|
||||
expect(deleteActionIcon).toHaveLength(2);
|
||||
|
||||
const editActionIcon = screen.queryAllByTestId('edit-alt');
|
||||
expect(editActionIcon).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should not renders correct action buttons without write permission', async () => {
|
||||
fetchMock.get(
|
||||
ruleInfoEndpoint,
|
||||
{ permissions: ['can_read'] },
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
await renderAndWait();
|
||||
|
||||
const deleteActionIcon = screen.queryByTestId('rls-list-trash-icon');
|
||||
expect(deleteActionIcon).not.toBeInTheDocument();
|
||||
|
||||
const editActionIcon = screen.queryByTestId('edit-alt');
|
||||
expect(editActionIcon).not.toBeInTheDocument();
|
||||
|
||||
fetchMock.get(
|
||||
ruleInfoEndpoint,
|
||||
{ permissions: ['can_read', 'can_write'] },
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('renders popover on new clicking rule button', async () => {
|
||||
await renderAndWait();
|
||||
|
||||
const modal = screen.queryByTestId('rls-modal-title');
|
||||
expect(modal).not.toBeInTheDocument();
|
||||
|
||||
const addRuleButton = await screen.findByTestId('add-rule');
|
||||
userEvent.click(addRuleButton);
|
||||
|
||||
const modalAfterClick = screen.queryByTestId('rls-modal-title');
|
||||
expect(modalAfterClick).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,350 @@
|
|||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { t, styled, SupersetClient } from '@superset-ui/core';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||
import Icons from 'src/components/Icons';
|
||||
import ListView, {
|
||||
FetchDataConfig,
|
||||
FilterOperator,
|
||||
ListViewProps,
|
||||
Filters,
|
||||
} from 'src/components/ListView';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import { Tooltip } from 'src/components/Tooltip';
|
||||
import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu';
|
||||
import rison from 'rison';
|
||||
import { useListViewResource } from 'src/views/CRUD/hooks';
|
||||
import RowLevelSecurityModal from 'src/features/rls/RowLevelSecurityModal';
|
||||
import { RLSObject } from 'src/features/rls/types';
|
||||
import { createErrorHandler } from 'src/views/CRUD/utils';
|
||||
|
||||
const Actions = styled.div`
|
||||
color: ${({ theme }) => theme.colors.grayscale.base};
|
||||
`;
|
||||
|
||||
interface RLSProps {
|
||||
addDangerToast: (msg: string) => void;
|
||||
addSuccessToast: (msg: string) => void;
|
||||
user: {
|
||||
userId?: string | number;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
}
|
||||
|
||||
function RowLevelSecurityList(props: RLSProps) {
|
||||
const { addDangerToast, addSuccessToast, user } = props;
|
||||
const [ruleModalOpen, setRuleModalOpen] = useState<boolean>(false);
|
||||
const [currentRule, setCurrentRule] = useState(null);
|
||||
|
||||
const {
|
||||
state: {
|
||||
loading,
|
||||
resourceCount: rulesCount,
|
||||
resourceCollection: rules,
|
||||
bulkSelectEnabled,
|
||||
},
|
||||
hasPerm,
|
||||
fetchData,
|
||||
refreshData,
|
||||
toggleBulkSelect,
|
||||
} = useListViewResource<RLSObject>(
|
||||
'rowlevelsecurity',
|
||||
t('Row Level Security'),
|
||||
addDangerToast,
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
function handleRuleEdit(rule: null) {
|
||||
setCurrentRule(rule);
|
||||
setRuleModalOpen(true);
|
||||
}
|
||||
|
||||
function handleRuleDelete(
|
||||
{ id, name }: RLSObject,
|
||||
refreshData: (arg0?: FetchDataConfig | null) => void,
|
||||
addSuccessToast: (arg0: string) => void,
|
||||
addDangerToast: (arg0: string) => void,
|
||||
) {
|
||||
return SupersetClient.delete({
|
||||
endpoint: `/api/v1/rowlevelsecurity/${id}`,
|
||||
}).then(
|
||||
() => {
|
||||
refreshData();
|
||||
addSuccessToast(t('Deleted %s', name));
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(t('There was an issue deleting %s: %s', name, errMsg)),
|
||||
),
|
||||
);
|
||||
}
|
||||
function handleBulkRulesDelete(rulesToDelete: RLSObject[]) {
|
||||
const ids = rulesToDelete.map(({ id }) => id);
|
||||
return SupersetClient.delete({
|
||||
endpoint: `/api/v1/rowlevelsecurity/?q=${rison.encode(ids)}`,
|
||||
}).then(
|
||||
() => {
|
||||
refreshData();
|
||||
addSuccessToast(t(`Deleted`));
|
||||
},
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(t('There was an issue deleting rules: %s', errMsg)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function handleRuleModalHide() {
|
||||
setCurrentRule(null);
|
||||
setRuleModalOpen(false);
|
||||
refreshData();
|
||||
}
|
||||
|
||||
const canWrite = hasPerm('can_write');
|
||||
const canEdit = hasPerm('can_write');
|
||||
const canExport = hasPerm('can_export');
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessor: 'name',
|
||||
Header: t('Name'),
|
||||
},
|
||||
{
|
||||
accessor: 'filter_type',
|
||||
Header: t('Filter Type'),
|
||||
size: 'xl',
|
||||
},
|
||||
{
|
||||
accessor: 'group_key',
|
||||
Header: t('Group Key'),
|
||||
size: 'xl',
|
||||
},
|
||||
{
|
||||
accessor: 'clause',
|
||||
Header: t('Clause'),
|
||||
},
|
||||
{
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { changed_on_delta_humanized: changedOn },
|
||||
},
|
||||
}: any) => <span className="no-wrap">{changedOn}</span>,
|
||||
Header: t('Modified'),
|
||||
accessor: 'changed_on_delta_humanized',
|
||||
size: 'xl',
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: any) => {
|
||||
const handleDelete = () =>
|
||||
handleRuleDelete(
|
||||
original,
|
||||
refreshData,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
);
|
||||
const handleEdit = () => handleRuleEdit(original);
|
||||
return (
|
||||
<Actions className="actions">
|
||||
{canWrite && (
|
||||
<ConfirmStatusChange
|
||||
title={t('Please confirm')}
|
||||
description={
|
||||
<>
|
||||
{t('Are you sure you want to delete')}{' '}
|
||||
<b>{original.name}</b>
|
||||
</>
|
||||
}
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
{confirmDelete => (
|
||||
<Tooltip
|
||||
id="delete-action-tooltip"
|
||||
title={t('Delete')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
<Icons.Trash data-test="rls-list-trash-icon" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ConfirmStatusChange>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Tooltip
|
||||
id="edit-action-tooltip"
|
||||
title={t('Edit')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="action-button"
|
||||
onClick={handleEdit}
|
||||
>
|
||||
<Icons.EditAlt data-test="edit-alt" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Actions>
|
||||
);
|
||||
},
|
||||
Header: t('Actions'),
|
||||
id: 'actions',
|
||||
hidden: !canEdit && !canWrite && !canExport,
|
||||
disableSortBy: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
user.userId,
|
||||
canEdit,
|
||||
canWrite,
|
||||
canExport,
|
||||
hasPerm,
|
||||
refreshData,
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
],
|
||||
);
|
||||
|
||||
const emptyState = {
|
||||
title: t('No Rules yet'),
|
||||
image: 'filter-results.svg',
|
||||
buttonAction: () => handleRuleEdit(null),
|
||||
buttonText: canEdit ? (
|
||||
<>
|
||||
<i className="fa fa-plus" data-test="add-rule-empty" /> {'Rule'}{' '}
|
||||
</>
|
||||
) : null,
|
||||
};
|
||||
|
||||
const filters: Filters = useMemo(
|
||||
() => [
|
||||
{
|
||||
Header: t('Name'),
|
||||
key: 'search',
|
||||
id: 'name',
|
||||
input: 'search',
|
||||
operator: FilterOperator.startsWith,
|
||||
},
|
||||
{
|
||||
Header: t('Filter Type'),
|
||||
key: 'filter_type',
|
||||
id: 'filter_type',
|
||||
input: 'select',
|
||||
operator: FilterOperator.equals,
|
||||
unfilteredLabel: t('Any'),
|
||||
selects: [
|
||||
{ label: t('Regular'), value: 'Regular' },
|
||||
{ label: t('Base'), value: 'Base' },
|
||||
],
|
||||
},
|
||||
{
|
||||
Header: t('Group Key'),
|
||||
key: 'search',
|
||||
id: 'group_key',
|
||||
input: 'search',
|
||||
operator: FilterOperator.startsWith,
|
||||
},
|
||||
],
|
||||
[user],
|
||||
);
|
||||
|
||||
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const subMenuButtons: SubMenuProps['buttons'] = [];
|
||||
|
||||
if (canWrite) {
|
||||
subMenuButtons.push({
|
||||
name: (
|
||||
<>
|
||||
<i className="fa fa-plus" data-test="add-rule" /> {t('Rule')}
|
||||
</>
|
||||
),
|
||||
buttonStyle: 'primary',
|
||||
onClick: () => handleRuleEdit(null),
|
||||
});
|
||||
subMenuButtons.push({
|
||||
name: t('Bulk select'),
|
||||
buttonStyle: 'secondary',
|
||||
'data-test': 'bulk-select',
|
||||
onClick: toggleBulkSelect,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubMenu name={t('Row Level Security')} buttons={subMenuButtons} />
|
||||
<ConfirmStatusChange
|
||||
title={t('Please confirm')}
|
||||
description={t('Are you sure you want to delete the selected rules?')}
|
||||
onConfirm={handleBulkRulesDelete}
|
||||
>
|
||||
{confirmDelete => {
|
||||
const bulkActions: ListViewProps['bulkActions'] = [];
|
||||
if (canWrite) {
|
||||
bulkActions.push({
|
||||
key: 'delete',
|
||||
name: t('Delete'),
|
||||
type: 'danger',
|
||||
onSelect: confirmDelete,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<RowLevelSecurityModal
|
||||
rule={currentRule}
|
||||
addDangerToast={addDangerToast}
|
||||
onHide={handleRuleModalHide}
|
||||
addSuccessToast={addSuccessToast}
|
||||
show={ruleModalOpen}
|
||||
/>
|
||||
<ListView<RLSObject>
|
||||
className="rls-list-view"
|
||||
bulkActions={bulkActions}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
disableBulkSelect={toggleBulkSelect}
|
||||
columns={columns}
|
||||
count={rulesCount}
|
||||
data={rules}
|
||||
emptyState={emptyState}
|
||||
fetchData={fetchData}
|
||||
filters={filters}
|
||||
initialSort={initialSort}
|
||||
loading={loading}
|
||||
pageSize={PAGE_SIZE}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</ConfirmStatusChange>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withToasts(RowLevelSecurityList);
|
|
@ -112,6 +112,13 @@ const Tags = lazy(
|
|||
() => import(/* webpackChunkName: "Tags" */ 'src/pages/Tags'),
|
||||
);
|
||||
|
||||
const RowLevelSecurityList = lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "RowLevelSecurityList" */ 'src/pages/RowLevelSecurityList'
|
||||
),
|
||||
);
|
||||
|
||||
type Routes = {
|
||||
path: string;
|
||||
Component: React.ComponentType;
|
||||
|
@ -206,6 +213,10 @@ export const routes: Routes = [
|
|||
path: '/dataset/:datasetId',
|
||||
Component: DatasetCreation,
|
||||
},
|
||||
{
|
||||
path: '/rowlevelsecurity/list',
|
||||
Component: RowLevelSecurityList,
|
||||
},
|
||||
];
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.TAGGING_SYSTEM)) {
|
||||
|
|
|
@ -53,6 +53,7 @@ from cachelib.base import BaseCache
|
|||
from celery.schedules import crontab
|
||||
from dateutil import tz
|
||||
from flask import Blueprint
|
||||
from flask_appbuilder.models.filters import BaseFilter
|
||||
from flask_appbuilder.security.manager import AUTH_DB
|
||||
from pandas._libs.parsers import STR_NA_VALUES # pylint: disable=no-name-in-module
|
||||
from sqlalchemy.orm.query import Query
|
||||
|
@ -1363,15 +1364,16 @@ TALISMAN_CONFIG = {
|
|||
}
|
||||
|
||||
# It is possible to customize which tables and roles are featured in the RLS
|
||||
# dropdown. When set, this dict is assigned to `add_form_query_rel_fields` and
|
||||
# `edit_form_query_rel_fields` on `RowLevelSecurityFiltersModelView`. Example:
|
||||
# dropdown. When set, this dict is assigned to `filter_rel_fields`
|
||||
# on `RLSRestApi`. Example:
|
||||
#
|
||||
# from flask_appbuilder.models.sqla import filters
|
||||
# RLS_FORM_QUERY_REL_FIELDS = {
|
||||
# "roles": [["name", filters.FilterStartsWith, "RlsRole"]]
|
||||
# "tables": [["table_name", filters.FilterContains, "rls"]]
|
||||
|
||||
# RLS_BASE_RELATED_FIELD_FILTERS = {
|
||||
# "tables": [["table_name", filters.FilterStartsWith, "birth"]],
|
||||
# "roles": [["name", filters.FilterContains, "Admin"]]
|
||||
# }
|
||||
RLS_FORM_QUERY_REL_FIELDS: Optional[Dict[str, List[List[Any]]]] = None
|
||||
RLS_BASE_RELATED_FIELD_FILTERS: Dict[str, BaseFilter] = {}
|
||||
|
||||
#
|
||||
# Flask session cookie options
|
||||
|
|
|
@ -17,10 +17,9 @@
|
|||
"""Views used by the SqlAlchemy connector"""
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, cast
|
||||
|
||||
from flask import current_app, flash, Markup, redirect
|
||||
from flask_appbuilder import CompactCRUDMixin, expose
|
||||
from flask import flash, Markup, redirect
|
||||
from flask_appbuilder import CompactCRUDMixin, expose, permission_name
|
||||
from flask_appbuilder.fieldwidgets import Select2Widget
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from flask_appbuilder.security.decorators import has_access
|
||||
|
@ -28,17 +27,17 @@ from flask_babel import lazy_gettext as _
|
|||
from wtforms.ext.sqlalchemy.fields import QuerySelectField
|
||||
from wtforms.validators import DataRequired, Regexp
|
||||
|
||||
from superset import app, db
|
||||
from superset import db
|
||||
from superset.connectors.base.views import DatasourceModelView
|
||||
from superset.connectors.sqla import models
|
||||
from superset.constants import MODEL_VIEW_RW_METHOD_PERMISSION_MAP, RouteMethod
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.utils import core as utils
|
||||
from superset.views.base import (
|
||||
BaseSupersetView,
|
||||
DatasourceFilter,
|
||||
DeleteMixin,
|
||||
ListWidgetWithCheckboxes,
|
||||
SupersetListWidget,
|
||||
SupersetModelView,
|
||||
YamlExportMixin,
|
||||
)
|
||||
|
@ -270,107 +269,15 @@ class SqlMetricInlineView( # pylint: disable=too-many-ancestors
|
|||
edit_form_extra_fields = add_form_extra_fields
|
||||
|
||||
|
||||
class RowLevelSecurityListWidget(
|
||||
SupersetListWidget
|
||||
): # pylint: disable=too-few-public-methods
|
||||
template = "superset/models/rls/list.html"
|
||||
class RowLevelSecurityView(BaseSupersetView):
|
||||
route_base = "/rowlevelsecurity"
|
||||
class_permission_name = "RowLevelSecurity"
|
||||
|
||||
def __init__(self, **kwargs: Any):
|
||||
kwargs["appbuilder"] = current_app.appbuilder
|
||||
super().__init__(**kwargs)
|
||||
|
||||
|
||||
class RowLevelSecurityFiltersModelView( # pylint: disable=too-many-ancestors
|
||||
SupersetModelView, DeleteMixin
|
||||
):
|
||||
datamodel = SQLAInterface(models.RowLevelSecurityFilter)
|
||||
|
||||
list_widget = cast(SupersetListWidget, RowLevelSecurityListWidget)
|
||||
|
||||
list_title = _("Row level security filter")
|
||||
show_title = _("Show Row level security filter")
|
||||
add_title = _("Add Row level security filter")
|
||||
edit_title = _("Edit Row level security filter")
|
||||
|
||||
list_columns = [
|
||||
"name",
|
||||
"filter_type",
|
||||
"tables",
|
||||
"roles",
|
||||
"clause",
|
||||
"creator",
|
||||
"modified",
|
||||
]
|
||||
order_columns = ["name", "filter_type", "clause", "modified"]
|
||||
edit_columns = [
|
||||
"name",
|
||||
"description",
|
||||
"filter_type",
|
||||
"tables",
|
||||
"roles",
|
||||
"group_key",
|
||||
"clause",
|
||||
]
|
||||
show_columns = edit_columns
|
||||
search_columns = (
|
||||
"name",
|
||||
"description",
|
||||
"filter_type",
|
||||
"tables",
|
||||
"roles",
|
||||
"group_key",
|
||||
"clause",
|
||||
)
|
||||
add_columns = edit_columns
|
||||
base_order = ("changed_on", "desc")
|
||||
description_columns = {
|
||||
"name": _("Choose a unique name"),
|
||||
"description": _("Optionally add a detailed description"),
|
||||
"filter_type": _(
|
||||
"Regular filters add where clauses to queries if a user belongs to a "
|
||||
"role referenced in the filter. Base filters apply filters to all queries "
|
||||
"except the roles defined in the filter, and can be used to define what "
|
||||
"users can see if no RLS filters within a filter group apply to them."
|
||||
),
|
||||
"tables": _("These are the tables this filter will be applied to."),
|
||||
"roles": _(
|
||||
"For regular filters, these are the roles this filter will be "
|
||||
"applied to. For base filters, these are the roles that the "
|
||||
"filter DOES NOT apply to, e.g. Admin if admin should see all "
|
||||
"data."
|
||||
),
|
||||
"group_key": _(
|
||||
"Filters with the same group key will be ORed together within the group, "
|
||||
"while different filter groups will be ANDed together. Undefined group "
|
||||
"keys are treated as unique groups, i.e. are not grouped together. "
|
||||
"For example, if a table has three filters, of which two are for "
|
||||
"departments Finance and Marketing (group key = 'department'), and one "
|
||||
"refers to the region Europe (group key = 'region'), the filter clause "
|
||||
"would apply the filter (department = 'Finance' OR department = "
|
||||
"'Marketing') AND (region = 'Europe')."
|
||||
),
|
||||
"clause": _(
|
||||
"This is the condition that will be added to the WHERE clause. "
|
||||
"For example, to only return rows for a particular client, "
|
||||
"you might define a regular filter with the clause `client_id = 9`. To "
|
||||
"display no rows unless a user belongs to a RLS filter role, a base "
|
||||
"filter can be created with the clause `1 = 0` (always false)."
|
||||
),
|
||||
}
|
||||
label_columns = {
|
||||
"name": _("Name"),
|
||||
"description": _("Description"),
|
||||
"tables": _("Tables"),
|
||||
"roles": _("Roles"),
|
||||
"clause": _("Clause"),
|
||||
"creator": _("Creator"),
|
||||
"modified": _("Modified"),
|
||||
}
|
||||
validators_columns = {"tables": [SelectDataRequired()]}
|
||||
|
||||
if app.config["RLS_FORM_QUERY_REL_FIELDS"]:
|
||||
add_form_query_rel_fields = app.config["RLS_FORM_QUERY_REL_FIELDS"]
|
||||
edit_form_query_rel_fields = add_form_query_rel_fields
|
||||
@expose("/list/")
|
||||
@has_access
|
||||
@permission_name("read")
|
||||
def list(self) -> FlaskResponse:
|
||||
return super().render_app_template()
|
||||
|
||||
|
||||
class TableModelView( # pylint: disable=too-many-ancestors
|
||||
|
|
|
@ -194,3 +194,14 @@ class BaseDAO:
|
|||
db.session.rollback()
|
||||
raise DAODeleteFailedError(exception=ex) from ex
|
||||
return model
|
||||
|
||||
@classmethod
|
||||
def bulk_delete(cls, models: List[Model], commit: bool = True) -> None:
|
||||
try:
|
||||
for model in models:
|
||||
cls.delete(model, False)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
except SQLAlchemyError as ex:
|
||||
db.session.rollback()
|
||||
raise DAODeleteFailedError(exception=ex) from ex
|
||||
|
|
|
@ -125,7 +125,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
|||
from superset.charts.api import ChartRestApi
|
||||
from superset.charts.data.api import ChartDataRestApi
|
||||
from superset.connectors.sqla.views import (
|
||||
RowLevelSecurityFiltersModelView,
|
||||
RowLevelSecurityView,
|
||||
SqlMetricInlineView,
|
||||
TableColumnInlineView,
|
||||
TableModelView,
|
||||
|
@ -150,6 +150,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
|||
from superset.queries.saved_queries.api import SavedQueryRestApi
|
||||
from superset.reports.api import ReportScheduleRestApi
|
||||
from superset.reports.logs.api import ReportExecutionLogRestApi
|
||||
from superset.row_level_security.api import RLSRestApi
|
||||
from superset.security.api import SecurityRestApi
|
||||
from superset.sqllab.api import SqlLabRestApi
|
||||
from superset.tags.api import TagRestApi
|
||||
|
@ -222,6 +223,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
|||
appbuilder.add_api(QueryRestApi)
|
||||
appbuilder.add_api(ReportScheduleRestApi)
|
||||
appbuilder.add_api(ReportExecutionLogRestApi)
|
||||
appbuilder.add_api(RLSRestApi)
|
||||
appbuilder.add_api(SavedQueryRestApi)
|
||||
appbuilder.add_api(TagRestApi)
|
||||
appbuilder.add_api(SqlLabRestApi)
|
||||
|
@ -289,14 +291,6 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
|||
category_label=__("Manage"),
|
||||
category_icon="",
|
||||
)
|
||||
appbuilder.add_view(
|
||||
RowLevelSecurityFiltersModelView,
|
||||
"Row Level Security",
|
||||
label=__("Row Level Security"),
|
||||
category="Security",
|
||||
category_label=__("Security"),
|
||||
icon="fa-lock",
|
||||
)
|
||||
|
||||
#
|
||||
# Setup views with no menu
|
||||
|
@ -435,6 +429,16 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
|||
menu_cond=lambda: bool(self.config["ENABLE_ACCESS_REQUEST"]),
|
||||
)
|
||||
|
||||
appbuilder.add_view(
|
||||
RowLevelSecurityView,
|
||||
"Row Level Security",
|
||||
href="/rowlevelsecurity/list/",
|
||||
label=__("Row Level Security"),
|
||||
category="Security",
|
||||
category_label=__("Security"),
|
||||
icon="fa-lock",
|
||||
)
|
||||
|
||||
def init_app_in_ctx(self) -> None:
|
||||
"""
|
||||
Runs init logic in the context of the app
|
||||
|
|
|
@ -0,0 +1,349 @@
|
|||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from flask import request, Response
|
||||
from flask_appbuilder.api import expose, protect, rison, safe
|
||||
from flask_appbuilder.models.sqla.interface import SQLAInterface
|
||||
from flask_babel import ngettext
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset import app
|
||||
from superset.commands.exceptions import (
|
||||
DatasourceNotFoundValidationError,
|
||||
RolesNotFoundValidationError,
|
||||
)
|
||||
from superset.connectors.sqla.models import RowLevelSecurityFilter
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod
|
||||
from superset.dao.exceptions import DAOCreateFailedError, DAOUpdateFailedError
|
||||
from superset.extensions import event_logger
|
||||
from superset.row_level_security.commands.bulk_delete import BulkDeleteRLSRuleCommand
|
||||
from superset.row_level_security.commands.create import CreateRLSRuleCommand
|
||||
from superset.row_level_security.commands.exceptions import RLSRuleNotFoundError
|
||||
from superset.row_level_security.commands.update import UpdateRLSRuleCommand
|
||||
from superset.row_level_security.schemas import (
|
||||
get_delete_ids_schema,
|
||||
RLSListSchema,
|
||||
RLSPostSchema,
|
||||
RLSPutSchema,
|
||||
RLSShowSchema,
|
||||
)
|
||||
from superset.views.base_api import (
|
||||
BaseSupersetModelRestApi,
|
||||
requires_json,
|
||||
statsd_metrics,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RLSRestApi(BaseSupersetModelRestApi):
|
||||
datamodel = SQLAInterface(RowLevelSecurityFilter)
|
||||
include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | {
|
||||
RouteMethod.RELATED,
|
||||
"bulk_delete",
|
||||
}
|
||||
resource_name = "rowlevelsecurity"
|
||||
class_permission_name = "Row Level Security"
|
||||
openapi_spec_tag = "Row Level Security"
|
||||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
allow_browser_login = True
|
||||
|
||||
list_columns = [
|
||||
"id",
|
||||
"name",
|
||||
"filter_type",
|
||||
"tables.id",
|
||||
"tables.table_name",
|
||||
"roles.id",
|
||||
"roles.name",
|
||||
"clause",
|
||||
"changed_on_delta_humanized",
|
||||
"group_key",
|
||||
]
|
||||
order_columns = [
|
||||
"name",
|
||||
"filter_type",
|
||||
"clause",
|
||||
"changed_on_delta_humanized",
|
||||
"group_key",
|
||||
]
|
||||
add_columns = [
|
||||
"name",
|
||||
"description",
|
||||
"filter_type",
|
||||
"tables",
|
||||
"roles",
|
||||
"group_key",
|
||||
"clause",
|
||||
]
|
||||
show_columns = [
|
||||
"name",
|
||||
"description",
|
||||
"filter_type",
|
||||
"tables.id",
|
||||
"tables.schema",
|
||||
"tables.table_name",
|
||||
"roles.id",
|
||||
"roles.name",
|
||||
"group_key",
|
||||
"clause",
|
||||
]
|
||||
search_columns = (
|
||||
"name",
|
||||
"description",
|
||||
"filter_type",
|
||||
"tables",
|
||||
"roles",
|
||||
"group_key",
|
||||
"clause",
|
||||
)
|
||||
edit_columns = add_columns
|
||||
|
||||
show_model_schema = RLSShowSchema()
|
||||
list_model_schema = RLSListSchema()
|
||||
add_model_schema = RLSPostSchema()
|
||||
edit_model_schema = RLSPutSchema()
|
||||
|
||||
allowed_rel_fields = {"tables", "roles"}
|
||||
base_related_field_filters = app.config["RLS_BASE_RELATED_FIELD_FILTERS"]
|
||||
|
||||
@expose("/", methods=["POST"])
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@requires_json
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def post(self) -> Response:
|
||||
"""Creates a new RLS rule
|
||||
---
|
||||
post:
|
||||
description: >-
|
||||
Create a new RLS Rule
|
||||
requestBody:
|
||||
description: RLS schema
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/{{self.__class__.__name__}}.post'
|
||||
responses:
|
||||
201:
|
||||
description: RLS Rule added
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
result:
|
||||
$ref: '#/components/schemas/{{self.__class__.__name__}}.post'
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
try:
|
||||
item = self.add_model_schema.load(request.json)
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
try:
|
||||
new_model = CreateRLSRuleCommand(item).run()
|
||||
return self.response(201, id=new_model.id, result=item)
|
||||
except RolesNotFoundValidationError as ex:
|
||||
logger.error(
|
||||
"Role not found while creating RLS rule %s: %s",
|
||||
self.__class__.__name__,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
except DatasourceNotFoundValidationError as ex:
|
||||
logger.error(
|
||||
"Table not found while creating RLS rule %s: %s",
|
||||
self.__class__.__name__,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
except DAOCreateFailedError as ex:
|
||||
logger.error(
|
||||
"Error creating RLS rule %s: %s",
|
||||
self.__class__.__name__,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
|
||||
@expose("/<int:pk>", methods=["PUT"])
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@requires_json
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.put",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def put(self, pk: int) -> Response:
|
||||
"""Updates an RLS Rule
|
||||
---
|
||||
put:
|
||||
description: >-
|
||||
Updates an RLS Rule
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: integer
|
||||
name: pk
|
||||
description: The Rule pk
|
||||
requestBody:
|
||||
description: RLS schema
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/{{self.__class__.__name__}}.put'
|
||||
responses:
|
||||
200:
|
||||
description: Rule changed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
result:
|
||||
$ref: '#/components/schemas/{{self.__class__.__name__}}.put'
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
|
||||
try:
|
||||
item = self.edit_model_schema.load(request.json)
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
try:
|
||||
new_model = UpdateRLSRuleCommand(pk, item).run()
|
||||
return self.response(201, id=new_model.id, result=item)
|
||||
except RolesNotFoundValidationError as ex:
|
||||
logger.error(
|
||||
"Role not found while updating RLS rule %s: %s",
|
||||
self.__class__.__name__,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
except DatasourceNotFoundValidationError as ex:
|
||||
logger.error(
|
||||
"Table not found while updating RLS rule %s: %s",
|
||||
self.__class__.__name__,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
except DAOUpdateFailedError as ex:
|
||||
logger.error(
|
||||
"Error updating RLS rule %s: %s",
|
||||
self.__class__.__name__,
|
||||
str(ex),
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
except RLSRuleNotFoundError as ex:
|
||||
return self.response_404()
|
||||
|
||||
@expose("/", methods=["DELETE"])
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@rison(get_delete_ids_schema)
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.bulk_delete",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def bulk_delete(self, **kwargs: Any) -> Response:
|
||||
"""Delete bulk RLS rules
|
||||
---
|
||||
delete:
|
||||
description: >-
|
||||
Deletes multiple RLS rules in a bulk operation.
|
||||
parameters:
|
||||
- in: query
|
||||
name: q
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/get_delete_ids_schema'
|
||||
responses:
|
||||
200:
|
||||
description: RLS Rule bulk delete
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
item_ids = kwargs["rison"]
|
||||
try:
|
||||
BulkDeleteRLSRuleCommand(item_ids).run()
|
||||
return self.response(
|
||||
200,
|
||||
message=ngettext(
|
||||
"Deleted %(num)d rules",
|
||||
"Deleted %(num)d rules",
|
||||
num=len(item_ids),
|
||||
),
|
||||
)
|
||||
except RLSRuleNotFoundError:
|
||||
return self.response_404()
|
|
@ -0,0 +1,16 @@
|
|||
# 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.
|
|
@ -0,0 +1,52 @@
|
|||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.dao.exceptions import DAODeleteFailedError
|
||||
from superset.reports.models import ReportSchedule
|
||||
from superset.row_level_security.commands.exceptions import (
|
||||
RLSRuleNotFoundError,
|
||||
RuleBulkDeleteFailedError,
|
||||
)
|
||||
from superset.row_level_security.dao import RLSDAO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BulkDeleteRLSRuleCommand(BaseCommand):
|
||||
def __init__(self, model_ids: List[int]):
|
||||
self._model_ids = model_ids
|
||||
self._models: List[ReportSchedule] = []
|
||||
|
||||
def run(self) -> None:
|
||||
self.validate()
|
||||
try:
|
||||
RLSDAO.bulk_delete(self._models)
|
||||
|
||||
return None
|
||||
except DAODeleteFailedError as ex:
|
||||
logger.exception(ex.exception)
|
||||
raise RuleBulkDeleteFailedError() from ex
|
||||
|
||||
def validate(self) -> None:
|
||||
# Validate/populate model exists
|
||||
self._models = RLSDAO.find_by_ids(self._model_ids)
|
||||
if not self._models or len(self._models) != len(self._model_ids):
|
||||
raise RLSRuleNotFoundError()
|
|
@ -0,0 +1,57 @@
|
|||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.exceptions import DatasourceNotFoundValidationError
|
||||
from superset.commands.utils import populate_roles
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.dao.exceptions import DAOCreateFailedError
|
||||
from superset.extensions import appbuilder, db, security_manager
|
||||
from superset.row_level_security.dao import RLSDAO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreateRLSRuleCommand(BaseCommand):
|
||||
def __init__(self, data: Dict[str, Any]):
|
||||
self._properties = data.copy()
|
||||
self._tables = self._properties.get("tables", [])
|
||||
self._roles = self._properties.get("roles", [])
|
||||
|
||||
def run(self) -> Any:
|
||||
self.validate()
|
||||
try:
|
||||
rule = RLSDAO.create(self._properties)
|
||||
except DAOCreateFailedError as ex:
|
||||
logger.exception(ex.exception)
|
||||
raise DAOCreateFailedError
|
||||
|
||||
return rule
|
||||
|
||||
def validate(self) -> None:
|
||||
roles = populate_roles(self._roles)
|
||||
tables = (
|
||||
db.session.query(SqlaTable).filter(SqlaTable.id.in_(self._tables)).all()
|
||||
)
|
||||
if len(tables) != len(self._tables):
|
||||
raise DatasourceNotFoundValidationError()
|
||||
self._properties["roles"] = roles
|
||||
self._properties["tables"] = tables
|
|
@ -0,0 +1,29 @@
|
|||
# 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 flask_babel import lazy_gettext as _
|
||||
|
||||
from superset.commands.exceptions import CommandException, DeleteFailedError
|
||||
|
||||
|
||||
class RLSRuleNotFoundError(CommandException):
|
||||
status = 404
|
||||
message = _("RLS Rule not found.")
|
||||
|
||||
|
||||
class RuleBulkDeleteFailedError(DeleteFailedError):
|
||||
message = _("RLS Rule could not be deleted.")
|
|
@ -0,0 +1,63 @@
|
|||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.exceptions import DatasourceNotFoundValidationError
|
||||
from superset.commands.utils import populate_roles
|
||||
from superset.connectors.sqla.models import RowLevelSecurityFilter, SqlaTable
|
||||
from superset.dao.exceptions import DAOUpdateFailedError
|
||||
from superset.extensions import appbuilder, db, security_manager
|
||||
from superset.row_level_security.commands.exceptions import RLSRuleNotFoundError
|
||||
from superset.row_level_security.dao import RLSDAO
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateRLSRuleCommand(BaseCommand):
|
||||
def __init__(self, model_id: int, data: Dict[str, Any]):
|
||||
self._model_id = model_id
|
||||
self._properties = data.copy()
|
||||
self._tables = self._properties.get("tables", [])
|
||||
self._roles = self._properties.get("roles", [])
|
||||
self._model: Optional[RowLevelSecurityFilter] = None
|
||||
|
||||
def run(self) -> Any:
|
||||
self.validate()
|
||||
try:
|
||||
rule = RLSDAO.update(self._model, self._properties)
|
||||
except DAOUpdateFailedError as ex:
|
||||
logger.exception(ex.exception)
|
||||
raise DAOUpdateFailedError
|
||||
|
||||
return rule
|
||||
|
||||
def validate(self) -> None:
|
||||
self._model = RLSDAO.find_by_id(int(self._model_id))
|
||||
if not self._model:
|
||||
raise RLSRuleNotFoundError()
|
||||
roles = populate_roles(self._roles)
|
||||
tables = (
|
||||
db.session.query(SqlaTable).filter(SqlaTable.id.in_(self._tables)).all()
|
||||
)
|
||||
if len(tables) != len(self._tables):
|
||||
raise DatasourceNotFoundValidationError()
|
||||
self._properties["roles"] = roles
|
||||
self._properties["tables"] = tables
|
|
@ -0,0 +1,23 @@
|
|||
# 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 superset.connectors.sqla.models import RowLevelSecurityFilter
|
||||
from superset.dao.base import BaseDAO
|
||||
|
||||
|
||||
class RLSDAO(BaseDAO):
|
||||
model_cls = RowLevelSecurityFilter
|
|
@ -0,0 +1,154 @@
|
|||
# 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 marshmallow import fields, Schema
|
||||
from marshmallow.validate import Length, OneOf
|
||||
|
||||
from superset.connectors.sqla.models import RowLevelSecurityFilter
|
||||
from superset.utils.core import RowLevelSecurityFilterType
|
||||
|
||||
id_description = "Unique if of rls filter"
|
||||
name_description = "Name of rls filter"
|
||||
description_description = "Detailed description"
|
||||
filter_type_description = "Regular filters add where clauses to queries if a user belongs to a role referenced in the filter, base filters apply filters to all queries except the roles defined in the filter, and can be used to define what users can see if no RLS filters within a filter group apply to them."
|
||||
tables_description = "These are the tables this filter will be applied to."
|
||||
roles_description = "For regular filters, these are the roles this filter will be applied to. For base filters, these are the roles that the filter DOES NOT apply to, e.g. Admin if admin should see all data."
|
||||
group_key_description = "Filters with the same group key will be ORed together within the group, while different filter groups will be ANDed together. Undefined group keys are treated as unique groups, i.e. are not grouped together. For example, if a table has three filters, of which two are for departments Finance and Marketing (group key = 'department'), and one refers to the region Europe (group key = 'region'), the filter clause would apply the filter (department = 'Finance' OR department = 'Marketing') AND (region = 'Europe')."
|
||||
clause_description = "This is the condition that will be added to the WHERE clause. For example, to only return rows for a particular client, you might define a regular filter with the clause `client_id = 9`. To display no rows unless a user belongs to a RLS filter role, a base filter can be created with the clause `1 = 0` (always false)."
|
||||
|
||||
get_delete_ids_schema = {"type": "array", "items": {"type": "integer"}}
|
||||
|
||||
|
||||
class RolesSchema(Schema):
|
||||
name = fields.String()
|
||||
id = fields.Integer()
|
||||
|
||||
|
||||
class TablesSchema(Schema):
|
||||
schema = fields.String()
|
||||
table_name = fields.String()
|
||||
id = fields.Integer()
|
||||
|
||||
|
||||
class RLSListSchema(Schema):
|
||||
id = fields.Integer(description=id_description)
|
||||
name = fields.String(description=name_description)
|
||||
filter_type = fields.String(
|
||||
description=filter_type_description,
|
||||
validate=OneOf(
|
||||
[filter_type.value for filter_type in RowLevelSecurityFilterType]
|
||||
),
|
||||
)
|
||||
roles = fields.List(fields.Nested(RolesSchema))
|
||||
tables = fields.List(fields.Nested(TablesSchema))
|
||||
clause = fields.String(description=clause_description)
|
||||
changed_on_delta_humanized = fields.Function(
|
||||
RowLevelSecurityFilter.created_on_delta_humanized
|
||||
)
|
||||
group_key = fields.String(description=group_key_description)
|
||||
description = fields.String(description=description_description)
|
||||
|
||||
|
||||
class RLSShowSchema(Schema):
|
||||
id = fields.Integer(description=id_description)
|
||||
name = fields.String(description=name_description)
|
||||
filter_type = fields.String(
|
||||
description=filter_type_description,
|
||||
validate=OneOf(
|
||||
[filter_type.value for filter_type in RowLevelSecurityFilterType]
|
||||
),
|
||||
)
|
||||
roles = fields.List(fields.Nested(RolesSchema))
|
||||
tables = fields.List(fields.Nested(TablesSchema))
|
||||
clause = fields.String(description=clause_description)
|
||||
group_key = fields.String(description=group_key_description)
|
||||
description = fields.String(description=description_description)
|
||||
|
||||
|
||||
class RLSPostSchema(Schema):
|
||||
name = fields.String(
|
||||
description=name_description,
|
||||
required=True,
|
||||
allow_none=False,
|
||||
validate=Length(1, 255),
|
||||
)
|
||||
description = fields.String(
|
||||
description=description_description, required=False, allow_none=True
|
||||
)
|
||||
filter_type = fields.String(
|
||||
description=filter_type_description,
|
||||
required=True,
|
||||
allow_none=False,
|
||||
validate=OneOf(
|
||||
[filter_type.value for filter_type in RowLevelSecurityFilterType]
|
||||
),
|
||||
)
|
||||
tables = fields.List(
|
||||
fields.Integer(),
|
||||
description=tables_description,
|
||||
required=True,
|
||||
allow_none=False,
|
||||
validate=Length(1),
|
||||
)
|
||||
roles = fields.List(
|
||||
fields.Integer(), description=roles_description, required=True, allow_none=False
|
||||
)
|
||||
group_key = fields.String(
|
||||
description=group_key_description, required=False, allow_none=True
|
||||
)
|
||||
clause = fields.String(
|
||||
description=clause_description, required=True, allow_none=False
|
||||
)
|
||||
|
||||
|
||||
class RLSPutSchema(Schema):
|
||||
name = fields.String(
|
||||
description=name_description,
|
||||
required=False,
|
||||
allow_none=False,
|
||||
validate=Length(1, 255),
|
||||
)
|
||||
description = fields.String(
|
||||
description=description_description, required=False, allow_none=True
|
||||
)
|
||||
filter_type = fields.String(
|
||||
description=filter_type_description,
|
||||
required=False,
|
||||
allow_none=False,
|
||||
validate=OneOf(
|
||||
[filter_type.value for filter_type in RowLevelSecurityFilterType]
|
||||
),
|
||||
)
|
||||
tables = fields.List(
|
||||
fields.Integer(),
|
||||
description=tables_description,
|
||||
required=False,
|
||||
allow_none=False,
|
||||
)
|
||||
roles = fields.List(
|
||||
fields.Integer(),
|
||||
description=roles_description,
|
||||
required=False,
|
||||
allow_none=False,
|
||||
)
|
||||
group_key = fields.String(
|
||||
description=group_key_description, required=False, allow_none=True
|
||||
)
|
||||
clause = fields.String(
|
||||
description=clause_description, required=False, allow_none=False
|
||||
)
|
|
@ -1,96 +0,0 @@
|
|||
{#
|
||||
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.
|
||||
#}
|
||||
{% extends 'appbuilder/general/widgets/base_list.html' %}
|
||||
{% import 'appbuilder/general/lib.html' as lib %}
|
||||
|
||||
{% block begin_content scoped %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
{% endblock %}
|
||||
|
||||
{% block begin_loop_header scoped %}
|
||||
<thead>
|
||||
<tr>
|
||||
{% if actions %}
|
||||
<th class="action_checkboxes">
|
||||
<input id="check_all" class="action_check_all" name="check_all" type="checkbox">
|
||||
</th>
|
||||
{% endif %}
|
||||
|
||||
{% if can_show or can_edit or can_delete %}
|
||||
<th class="col-md-1 col-lg-1 col-sm-1" ></th>
|
||||
{% endif %}
|
||||
|
||||
{% for item in include_columns %}
|
||||
{% if item in order_columns %}
|
||||
{% set res = item | get_link_order(modelview_name) %}
|
||||
{% if res == 2 %}
|
||||
<th><a href={{ item | link_order(modelview_name) }}>{{label_columns.get(item)}}
|
||||
<i class="fa fa-chevron-up pull-right"></i></a></th>
|
||||
{% elif res == 1 %}
|
||||
<th><a href={{ item | link_order(modelview_name) }}>{{label_columns.get(item)}}
|
||||
<i class="fa fa-chevron-down pull-right"></i></a></th>
|
||||
{% else %}
|
||||
<th><a href={{ item | link_order(modelview_name) }}>{{label_columns.get(item)}}
|
||||
<i class="fa fa-arrows-v pull-right"></i></a></th>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<th>{{label_columns.get(item)}}</th>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
{% endblock %}
|
||||
|
||||
{% block begin_loop_values %}
|
||||
{% for item in value_columns %}
|
||||
{% set pk = pks[loop.index-1] %}
|
||||
<tr>
|
||||
{% if actions %}
|
||||
<td>
|
||||
<input id="{{pk}}" class="action_check" name="rowid" value="{{pk}}" type="checkbox">
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if can_show or can_edit or can_delete %}
|
||||
<td><center>
|
||||
{{ lib.btn_crud(can_show, can_edit, can_delete, pk, modelview_name, filters) }}
|
||||
</center></td>
|
||||
{% endif %}
|
||||
{% for value in include_columns %}
|
||||
<td>
|
||||
{% if value == "roles" and item["filter_type"] == "Base" and not item[value] %}
|
||||
All
|
||||
{% elif value == "roles" and item["filter_type"] == 'Base' %}
|
||||
Not {{ item[value] }}
|
||||
{% elif value == "roles" and item["filter_type"] == 'Regular' and not item[value] %}
|
||||
None
|
||||
{% elif value == "group_key" and item[value] == None %}
|
||||
{% else %}
|
||||
{{ item[value] }}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
|
||||
{% block end_content scoped %}
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -21,13 +21,18 @@ from unittest import mock
|
|||
|
||||
import pytest
|
||||
from flask import g
|
||||
import json
|
||||
import prison
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset import db, security_manager, app
|
||||
from superset.connectors.sqla.models import RowLevelSecurityFilter, SqlaTable
|
||||
from superset.security.guest_token import (
|
||||
GuestTokenResourceType,
|
||||
GuestUser,
|
||||
)
|
||||
from flask_babel import lazy_gettext as _
|
||||
from flask_appbuilder.models.sqla import filters
|
||||
from ..conftest import with_config
|
||||
from ..base_tests import SupersetTestCase
|
||||
from tests.integration_tests.fixtures.birth_names_dashboard import (
|
||||
load_birth_names_dashboard_with_slices,
|
||||
|
@ -38,6 +43,7 @@ from tests.integration_tests.fixtures.energy_dashboard import (
|
|||
load_energy_table_data,
|
||||
)
|
||||
from tests.integration_tests.fixtures.unicode_dashboard import (
|
||||
UNICODE_TBL_NAME,
|
||||
load_unicode_dashboard_with_slice,
|
||||
load_unicode_data,
|
||||
)
|
||||
|
@ -173,19 +179,18 @@ class TestRowLevelSecurity(SupersetTestCase):
|
|||
self.login(username="admin")
|
||||
test_dataset = self._get_test_dataset()
|
||||
rv = self.client.post(
|
||||
"/rowlevelsecurityfiltersmodelview/add",
|
||||
data=dict(
|
||||
name="rls1",
|
||||
description="Some description",
|
||||
filter_type="Regular",
|
||||
tables=[test_dataset.id],
|
||||
roles=[security_manager.find_role("Alpha").id],
|
||||
group_key="group_key_1",
|
||||
clause="client_id=1",
|
||||
),
|
||||
follow_redirects=True,
|
||||
"/api/v1/rowlevelsecurity/",
|
||||
json={
|
||||
"name": "rls1",
|
||||
"description": "Some description",
|
||||
"filter_type": "Regular",
|
||||
"tables": [test_dataset.id],
|
||||
"roles": [security_manager.find_role("Alpha").id],
|
||||
"group_key": "group_key_1",
|
||||
"clause": "client_id=1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
self.assertEqual(rv.status_code, 201)
|
||||
rls1 = (
|
||||
db.session.query(RowLevelSecurityFilter).filter_by(name="rls1")
|
||||
).one_or_none()
|
||||
|
@ -200,41 +205,39 @@ class TestRowLevelSecurity(SupersetTestCase):
|
|||
self.login(username="admin")
|
||||
test_dataset = self._get_test_dataset()
|
||||
rv = self.client.post(
|
||||
"/rowlevelsecurityfiltersmodelview/add",
|
||||
data=dict(
|
||||
name="rls_entry1",
|
||||
description="Some description",
|
||||
filter_type="Regular",
|
||||
tables=[test_dataset.id],
|
||||
roles=[security_manager.find_role("Alpha").id],
|
||||
group_key="group_key_1",
|
||||
clause="client_id=1",
|
||||
),
|
||||
follow_redirects=True,
|
||||
"/api/v1/rowlevelsecurity/",
|
||||
json={
|
||||
"name": "rls_entry1",
|
||||
"description": "Some description",
|
||||
"filter_type": "Regular",
|
||||
"tables": [test_dataset.id],
|
||||
"roles": [security_manager.find_role("Alpha").id],
|
||||
"group_key": "group_key_1",
|
||||
"clause": "client_id=1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
data = rv.data.decode("utf-8")
|
||||
assert "Already exists." in data
|
||||
self.assertEqual(rv.status_code, 422)
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
assert "Create failed" in data["message"]
|
||||
|
||||
@pytest.mark.usefixtures("create_dataset")
|
||||
def test_model_view_rls_add_tables_required(self):
|
||||
self.login(username="admin")
|
||||
rv = self.client.post(
|
||||
"/rowlevelsecurityfiltersmodelview/add",
|
||||
data=dict(
|
||||
name="rls1",
|
||||
description="Some description",
|
||||
filter_type="Regular",
|
||||
tables=[],
|
||||
roles=[security_manager.find_role("Alpha").id],
|
||||
group_key="group_key_1",
|
||||
clause="client_id=1",
|
||||
),
|
||||
follow_redirects=True,
|
||||
"/api/v1/rowlevelsecurity/",
|
||||
json={
|
||||
"name": "rls1",
|
||||
"description": "Some description",
|
||||
"filter_type": "Regular",
|
||||
"tables": [],
|
||||
"roles": [security_manager.find_role("Alpha").id],
|
||||
"group_key": "group_key_1",
|
||||
"clause": "client_id=1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
data = rv.data.decode("utf-8")
|
||||
assert "This field is required." in data
|
||||
self.assertEqual(rv.status_code, 400)
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
assert data["message"] == {"tables": ["Shorter than minimum length 1."]}
|
||||
|
||||
@pytest.mark.usefixtures("load_energy_table_with_slice")
|
||||
def test_rls_filter_alters_energy_query(self):
|
||||
|
@ -303,6 +306,340 @@ class TestRowLevelSecurity(SupersetTestCase):
|
|||
assert not self.BASE_FILTER_REGEX.search(sql)
|
||||
|
||||
|
||||
class TestRowLevelSecurityCreateAPI(SupersetTestCase):
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_invalid_role_failure(self):
|
||||
self.login("Admin")
|
||||
payload = {
|
||||
"name": "rls 1",
|
||||
"clause": "1=1",
|
||||
"filter_type": "Base",
|
||||
"tables": [1],
|
||||
"roles": [999999],
|
||||
}
|
||||
rv = self.client.post("/api/v1/rowlevelsecurity/", json=payload)
|
||||
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
|
||||
self.assertEqual(status_code, 422)
|
||||
self.assertEqual(data["message"], "[l'Some roles do not exist']")
|
||||
|
||||
def test_invalid_table_failure(self):
|
||||
self.login("Admin")
|
||||
payload = {
|
||||
"name": "rls 1",
|
||||
"clause": "1=1",
|
||||
"filter_type": "Base",
|
||||
"tables": [999999],
|
||||
"roles": [1],
|
||||
}
|
||||
rv = self.client.post("/api/v1/rowlevelsecurity/", json=payload)
|
||||
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
|
||||
self.assertEqual(status_code, 422)
|
||||
self.assertEqual(data["message"], "[l'Datasource does not exist']")
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_post_success(self):
|
||||
table = db.session.query(SqlaTable).first()
|
||||
self.login("Admin")
|
||||
payload = {
|
||||
"name": "rls 1",
|
||||
"clause": "1=1",
|
||||
"filter_type": "Base",
|
||||
"tables": [table.id],
|
||||
"roles": [1],
|
||||
}
|
||||
rv = self.client.post("/api/v1/rowlevelsecurity/", json=payload)
|
||||
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
|
||||
|
||||
self.assertEqual(status_code, 201)
|
||||
|
||||
rls = (
|
||||
db.session.query(RowLevelSecurityFilter)
|
||||
.filter(RowLevelSecurityFilter.id == data["id"])
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
assert rls
|
||||
self.assertEqual(rls.name, "rls 1")
|
||||
self.assertEqual(rls.clause, "1=1")
|
||||
self.assertEqual(rls.filter_type, "Base")
|
||||
self.assertEqual(rls.tables[0].id, table.id)
|
||||
self.assertEqual(rls.roles[0].id, 1)
|
||||
|
||||
db.session.delete(rls)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class TestRowLevelSecurityUpdateAPI(SupersetTestCase):
|
||||
def test_invalid_id_failure(self):
|
||||
self.login("Admin")
|
||||
payload = {
|
||||
"name": "rls 1",
|
||||
"clause": "1=1",
|
||||
"filter_type": "Base",
|
||||
"tables": [1],
|
||||
"roles": [1],
|
||||
}
|
||||
rv = self.client.put("/api/v1/rowlevelsecurity/99999999", json=payload)
|
||||
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
|
||||
self.assertEqual(status_code, 404)
|
||||
self.assertEqual(data["message"], "Not found")
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_invalid_role_failure(self):
|
||||
table = db.session.query(SqlaTable).first()
|
||||
|
||||
rls = RowLevelSecurityFilter(
|
||||
name="rls test invalid role",
|
||||
clause="1=1",
|
||||
filter_type="Regular",
|
||||
tables=[table],
|
||||
)
|
||||
db.session.add(rls)
|
||||
db.session.commit()
|
||||
|
||||
self.login("Admin")
|
||||
payload = {
|
||||
"roles": [999999],
|
||||
}
|
||||
rv = self.client.put(f"/api/v1/rowlevelsecurity/{rls.id}", json=payload)
|
||||
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
|
||||
self.assertEqual(status_code, 422)
|
||||
self.assertEqual(data["message"], "[l'Some roles do not exist']")
|
||||
|
||||
db.session.delete(rls)
|
||||
db.session.commit()
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
def test_invalid_table_failure(self):
|
||||
table = db.session.query(SqlaTable).first()
|
||||
|
||||
rls = RowLevelSecurityFilter(
|
||||
name="rls test invalid role",
|
||||
clause="1=1",
|
||||
filter_type="Regular",
|
||||
tables=[table],
|
||||
)
|
||||
db.session.add(rls)
|
||||
db.session.commit()
|
||||
|
||||
self.login("Admin")
|
||||
payload = {
|
||||
"name": "rls 1",
|
||||
"clause": "1=1",
|
||||
"filter_type": "Base",
|
||||
"tables": [999999],
|
||||
"roles": [1],
|
||||
}
|
||||
rv = self.client.put(f"/api/v1/rowlevelsecurity/{rls.id}", json=payload)
|
||||
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
|
||||
self.assertEqual(status_code, 422)
|
||||
self.assertEqual(data["message"], "[l'Datasource does not exist']")
|
||||
|
||||
db.session.delete(rls)
|
||||
db.session.commit()
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
@pytest.mark.usefixtures("load_energy_table_with_slice")
|
||||
def test_put_success(self):
|
||||
tables = db.session.query(SqlaTable).limit(2).all()
|
||||
roles = db.session.query(security_manager.role_model).limit(2).all()
|
||||
|
||||
rls = RowLevelSecurityFilter(
|
||||
name="rls 1",
|
||||
clause="1=1",
|
||||
filter_type="Regular",
|
||||
tables=[tables[0]],
|
||||
roles=[roles[0]],
|
||||
)
|
||||
db.session.add(rls)
|
||||
db.session.commit()
|
||||
|
||||
self.login("Admin")
|
||||
payload = {
|
||||
"name": "rls put success",
|
||||
"clause": "2=2",
|
||||
"filter_type": "Base",
|
||||
"tables": [tables[1].id],
|
||||
"roles": [roles[1].id],
|
||||
}
|
||||
rv = self.client.put(f"/api/v1/rowlevelsecurity/{rls.id}", json=payload)
|
||||
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
|
||||
|
||||
self.assertEqual(status_code, 201)
|
||||
|
||||
rls = (
|
||||
db.session.query(RowLevelSecurityFilter)
|
||||
.filter(RowLevelSecurityFilter.id == rls.id)
|
||||
.one_or_none()
|
||||
)
|
||||
|
||||
self.assertEqual(rls.name, "rls put success")
|
||||
self.assertEqual(rls.clause, "2=2")
|
||||
self.assertEqual(rls.filter_type, "Base")
|
||||
self.assertEqual(rls.tables[0].id, tables[1].id)
|
||||
self.assertEqual(rls.roles[0].id, roles[1].id)
|
||||
|
||||
db.session.delete(rls)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
class TestRowLevelSecurityBulkDeleteAPI(SupersetTestCase):
|
||||
def test_invalid_id_failure(self):
|
||||
self.login("Admin")
|
||||
|
||||
ids_to_delete = prison.dumps([10000, 10001, 100002])
|
||||
rv = self.client.delete(f"/api/v1/rowlevelsecurity/?q={ids_to_delete}")
|
||||
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
|
||||
|
||||
self.assertEqual(status_code, 404)
|
||||
self.assertEqual(data["message"], "Not found")
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
@pytest.mark.usefixtures("load_energy_table_with_slice")
|
||||
def test_bulk_delete_success(self):
|
||||
tables = db.session.query(SqlaTable).limit(2).all()
|
||||
roles = db.session.query(security_manager.role_model).limit(2).all()
|
||||
|
||||
rls_1 = RowLevelSecurityFilter(
|
||||
name="rls 1",
|
||||
clause="1=1",
|
||||
filter_type="Regular",
|
||||
tables=[tables[0]],
|
||||
roles=[roles[0]],
|
||||
)
|
||||
rls_2 = RowLevelSecurityFilter(
|
||||
name="rls 2",
|
||||
clause="2=2",
|
||||
filter_type="Base",
|
||||
tables=[tables[1]],
|
||||
roles=[roles[1]],
|
||||
)
|
||||
db.session.add_all([rls_1, rls_2])
|
||||
db.session.commit()
|
||||
|
||||
self.login("Admin")
|
||||
|
||||
ids_to_delete = prison.dumps([rls_1.id, rls_2.id])
|
||||
rv = self.client.delete(f"/api/v1/rowlevelsecurity/?q={ids_to_delete}")
|
||||
status_code, data = rv.status_code, json.loads(rv.data.decode("utf-8"))
|
||||
|
||||
self.assertEqual(status_code, 200)
|
||||
self.assertEqual(data["message"], "Deleted 2 rules")
|
||||
|
||||
|
||||
class TestRowLevelSecurityWithRelatedAPI(SupersetTestCase):
|
||||
@pytest.mark.usefixtures("load_birth_names_data")
|
||||
@pytest.mark.usefixtures("load_energy_table_data")
|
||||
def test_rls_tables_related_api(self):
|
||||
self.login("Admin")
|
||||
|
||||
params = prison.dumps({"page": 0, "page_size": 100})
|
||||
|
||||
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
result = data["result"]
|
||||
|
||||
db_tables = db.session.query(SqlaTable).all()
|
||||
|
||||
db_table_names = set([t.name for t in db_tables])
|
||||
received_tables = set([table["text"] for table in result])
|
||||
|
||||
assert data["count"] == len(db_tables)
|
||||
assert len(result) == len(db_tables)
|
||||
assert db_table_names == received_tables
|
||||
|
||||
def test_rls_roles_related_api(self):
|
||||
self.login("Admin")
|
||||
params = prison.dumps({"page": 0, "page_size": 100})
|
||||
|
||||
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/roles?q={params}")
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
result = data["result"]
|
||||
|
||||
db_role_names = set([r.name for r in security_manager.get_all_roles()])
|
||||
received_roles = set([role["text"] for role in result])
|
||||
|
||||
assert data["count"] == len(db_role_names)
|
||||
assert len(result) == len(db_role_names)
|
||||
assert db_role_names == received_roles
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
@pytest.mark.usefixtures("load_energy_table_with_slice")
|
||||
@mock.patch(
|
||||
"superset.row_level_security.api.RLSRestApi.base_related_field_filters",
|
||||
{"tables": [["table_name", filters.FilterStartsWith, "birth"]]},
|
||||
)
|
||||
def test_table_related_filter(self):
|
||||
self.login("Admin")
|
||||
|
||||
params = prison.dumps({"page": 0, "page_size": 10})
|
||||
|
||||
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
result = data["result"]
|
||||
received_tables = set([table["text"].split(".")[-1] for table in result])
|
||||
|
||||
assert data["count"] == 1
|
||||
assert len(result) == 1
|
||||
assert {"birth_names"} == received_tables
|
||||
|
||||
@mock.patch(
|
||||
"superset.row_level_security.api.RLSRestApi.base_related_field_filters",
|
||||
{"roles": [["name", filters.FilterEqual, "Admin"]]},
|
||||
)
|
||||
def test_role_related_filter(self):
|
||||
self.login("Admin")
|
||||
|
||||
params = prison.dumps({"page": 0, "page_size": 10})
|
||||
|
||||
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/roles?q={params}")
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
result = data["result"]
|
||||
received_roles = set([role["text"] for role in result])
|
||||
|
||||
assert data["count"] == 1
|
||||
assert len(result) == 1
|
||||
assert {"Admin"} == received_roles
|
||||
|
||||
@pytest.mark.usefixtures("load_birth_names_dashboard_with_slices")
|
||||
@pytest.mark.usefixtures("load_energy_table_with_slice")
|
||||
@mock.patch(
|
||||
"superset.row_level_security.api.RLSRestApi.base_related_field_filters",
|
||||
{
|
||||
"tables": [["table_name", filters.FilterStartsWith, "birth"]],
|
||||
"roles": [["name", filters.FilterEqual, "Admin"]],
|
||||
},
|
||||
)
|
||||
def test_table_and_role_related_filter(self):
|
||||
self.login("Admin")
|
||||
|
||||
params = prison.dumps({"page": 0, "page_size": 10})
|
||||
|
||||
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/tables?q={params}")
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
result = data["result"]
|
||||
received_tables = set([table["text"].split(".")[-1] for table in result])
|
||||
|
||||
assert data["count"] == 1
|
||||
assert len(result) == 1
|
||||
assert {"birth_names"} == received_tables
|
||||
|
||||
rv = self.client.get(f"/api/v1/rowlevelsecurity/related/roles?q={params}")
|
||||
self.assertEqual(rv.status_code, 200)
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
result = data["result"]
|
||||
received_roles = set([role["text"] for role in result])
|
||||
|
||||
assert data["count"] == 1
|
||||
assert len(result) == 1
|
||||
assert {"Admin"} == received_roles
|
||||
|
||||
|
||||
RLS_ALICE_REGEX = re.compile(r"name = 'Alice'")
|
||||
RLS_GENDER_REGEX = re.compile(r"AND \(gender = 'girl'\)")
|
||||
|
||||
|
|
Loading…
Reference in New Issue