diff --git a/UPDATING.md b/UPDATING.md index 9afd8d353..12e69b373 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -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 diff --git a/superset-frontend/src/features/rls/RowLevelSecurityModal.test.tsx b/superset-frontend/src/features/rls/RowLevelSecurityModal.test.tsx new file mode 100644 index 000000000..6253c42c8 --- /dev/null +++ b/superset-frontend/src/features/rls/RowLevelSecurityModal.test.tsx @@ -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(, { 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); + }); +}); diff --git a/superset-frontend/src/features/rls/RowLevelSecurityModal.tsx b/superset-frontend/src/features/rls/RowLevelSecurityModal.tsx new file mode 100644 index 000000000..1fc0b597d --- /dev/null +++ b/superset-frontend/src/features/rls/RowLevelSecurityModal.tsx @@ -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({ + ...DEAFULT_RULE, + }); + const [disableSave, setDisableSave] = useState(true); + + const isEditMode = rule !== null; + + // * hooks * + const { + state: { loading, resource, error: fetchError }, + fetchResource, + createResource, + updateResource, + clearError, + } = useSingleViewResource( + `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) => { + updateRuleState('tables', tables || []); + }; + + const onRolesChange = (roles: Array) => { + 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 ( + + {isEditMode ? ( + + ) : ( + + )} + {isEditMode ? t('Edit Rule') : t('Add Rule')} + + } + > + +
+ + + onTextChange(target), + }} + css={noBottomMargin} + label={t('Rule Name')} + data-test="rule-name-test" + /> + + + +
+ {t('Filter Type')}{' '} + +
+
+