You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2023/01/09 20:34:30 UTC

[superset] branch master updated: feat(RLS): RESTful apis and react view for RLS (#22325)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 159dcd7e62 feat(RLS): RESTful apis and react view for RLS (#22325)
159dcd7e62 is described below

commit 159dcd7e62e9466e2da4ad81cd25c06770fb4a5e
Author: Mayur <ma...@gmail.com>
AuthorDate: Tue Jan 10 02:04:20 2023 +0530

    feat(RLS): RESTful apis and react view for RLS (#22325)
---
 UPDATING.md                                        |   1 +
 .../rowlevelsecurity/RowLevelSecurityList.test.tsx | 259 +++++++++++
 .../CRUD/rowlevelsecurity/RowLevelSecurityList.tsx | 351 +++++++++++++++
 .../RowLevelSecurityModal.test.tsx                 | 295 +++++++++++++
 .../rowlevelsecurity/RowLevelSecurityModal.tsx     | 480 +++++++++++++++++++++
 .../src/views/CRUD/rowlevelsecurity/constants.ts   |  31 ++
 .../src/views/CRUD/rowlevelsecurity/types.ts       |  51 +++
 superset-frontend/src/views/routes.tsx             |  10 +
 superset/config.py                                 |  14 +-
 superset/connectors/sqla/views.py                  | 117 +----
 superset/dao/base.py                               |  11 +
 superset/initialization/__init__.py                |  22 +-
 superset/row_level_security/api.py                 | 349 +++++++++++++++
 superset/row_level_security/commands/__init__.py   |  16 +
 .../row_level_security/commands/bulk_delete.py     |  52 +++
 superset/row_level_security/commands/create.py     |  57 +++
 superset/row_level_security/commands/exceptions.py |  29 ++
 superset/row_level_security/commands/update.py     |  63 +++
 superset/row_level_security/dao.py                 |  23 +
 superset/row_level_security/schemas.py             | 154 +++++++
 .../security/row_level_security_tests.py           | 419 ++++++++++++++++--
 21 files changed, 2643 insertions(+), 161 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index 51b77995d6..8ae0b1da4a 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -36,6 +36,7 @@ assists people when migrating to a new version.
 
 - [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 CR [...]
+- [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/views/CRUD/rowlevelsecurity/RowLevelSecurityList.test.tsx b/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityList.test.tsx
new file mode 100644
index 0000000000..8a948bd997
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityList.test.tsx
@@ -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 RowLevelSecurityList from 'src/views/CRUD/rowlevelsecurity/RowLevelSecurityList';
+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';
+
+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();
+  });
+});
diff --git a/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityList.tsx b/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityList.tsx
new file mode 100644
index 0000000000..a7dfa2058c
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityList.tsx
@@ -0,0 +1,351 @@
+/**
+ * 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/views/components/SubMenu';
+import rison from 'rison';
+import { useListViewResource } from '../hooks';
+import RowLevelSecurityModal from './RowLevelSecurityModal';
+import { RLSObject } from './types';
+import { createErrorHandler } from '../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);
diff --git a/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityModal.test.tsx b/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityModal.test.tsx
new file mode 100644
index 0000000000..6253c42c82
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/rowlevelsecurity/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(<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);
+  });
+});
diff --git a/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityModal.tsx b/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityModal.tsx
new file mode 100644
index 0000000000..1498527c1f
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/rowlevelsecurity/RowLevelSecurityModal.tsx
@@ -0,0 +1,480 @@
+/**
+ * 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 '../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 =  [...]
+              )}
+              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;
diff --git a/superset-frontend/src/views/CRUD/rowlevelsecurity/constants.ts b/superset-frontend/src/views/CRUD/rowlevelsecurity/constants.ts
new file mode 100644
index 0000000000..ceb0982c5f
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/rowlevelsecurity/constants.ts
@@ -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',
+  },
+];
diff --git a/superset-frontend/src/views/CRUD/rowlevelsecurity/types.ts b/superset-frontend/src/views/CRUD/rowlevelsecurity/types.ts
new file mode 100644
index 0000000000..ae0166c8e1
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/rowlevelsecurity/types.ts
@@ -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;
+};
diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx
index 88efa00a64..0898ea74df 100644
--- a/superset-frontend/src/views/routes.tsx
+++ b/superset-frontend/src/views/routes.tsx
@@ -110,6 +110,12 @@ const SavedQueryList = lazy(
       /* webpackChunkName: "SavedQueryList" */ 'src/views/CRUD/data/savedquery/SavedQueryList'
     ),
 );
+const RowLevelSecurity = lazy(
+  () =>
+    import(
+      /* webpackChunkName: "RowLevelSecurity" */ 'src/views/CRUD/rowlevelsecurity/RowLevelSecurityList'
+    ),
+);
 
 type Routes = {
   path: string;
@@ -205,6 +211,10 @@ export const routes: Routes = [
     path: '/dataset/:datasetId',
     Component: AddDataset,
   },
+  {
+    path: '/rowlevelsecurity/list',
+    Component: RowLevelSecurity,
+  },
 ];
 
 const frontEndRoutes = routes
diff --git a/superset/config.py b/superset/config.py
index d8ff88978e..8a1b5220db 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -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
@@ -1351,15 +1352,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
diff --git a/superset/connectors/sqla/views.py b/superset/connectors/sqla/views.py
index c502f527ac..eca84fc646 100644
--- a/superset/connectors/sqla/views.py
+++ b/superset/connectors/sqla/views.py
@@ -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,18 +27,18 @@ 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,
     create_table_permissions,
     DatasourceFilter,
     DeleteMixin,
     ListWidgetWithCheckboxes,
-    SupersetListWidget,
     SupersetModelView,
     YamlExportMixin,
 )
@@ -271,107 +270,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
diff --git a/superset/dao/base.py b/superset/dao/base.py
index c6890e53a5..660b4fe601 100644
--- a/superset/dao/base.py
+++ b/superset/dao/base.py
@@ -188,3 +188,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
diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py
index 1cffbd0dc2..7e37781502 100644
--- a/superset/initialization/__init__.py
+++ b/superset/initialization/__init__.py
@@ -123,7 +123,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,
@@ -147,6 +147,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.views.access_requests import AccessRequestsModelView
         from superset.views.alerts import AlertView, ReportView
@@ -215,6 +216,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)
         #
         # Setup regular views
@@ -280,14 +282,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
@@ -409,6 +403,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
diff --git a/superset/row_level_security/api.py b/superset/row_level_security/api.py
new file mode 100644
index 0000000000..76a04d93ab
--- /dev/null
+++ b/superset/row_level_security/api.py
@@ -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()
diff --git a/superset/row_level_security/commands/__init__.py b/superset/row_level_security/commands/__init__.py
new file mode 100644
index 0000000000..13a83393a9
--- /dev/null
+++ b/superset/row_level_security/commands/__init__.py
@@ -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.
diff --git a/superset/row_level_security/commands/bulk_delete.py b/superset/row_level_security/commands/bulk_delete.py
new file mode 100644
index 0000000000..90a37cc6d2
--- /dev/null
+++ b/superset/row_level_security/commands/bulk_delete.py
@@ -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, Optional
+
+from superset import security_manager
+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: Optional[List[ReportSchedule]] = None
+
+    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()
diff --git a/superset/row_level_security/commands/create.py b/superset/row_level_security/commands/create.py
new file mode 100644
index 0000000000..2c1d4f7b6a
--- /dev/null
+++ b/superset/row_level_security/commands/create.py
@@ -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
diff --git a/superset/row_level_security/commands/exceptions.py b/superset/row_level_security/commands/exceptions.py
new file mode 100644
index 0000000000..40f8e4af81
--- /dev/null
+++ b/superset/row_level_security/commands/exceptions.py
@@ -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.")
diff --git a/superset/row_level_security/commands/update.py b/superset/row_level_security/commands/update.py
new file mode 100644
index 0000000000..08964601b3
--- /dev/null
+++ b/superset/row_level_security/commands/update.py
@@ -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
diff --git a/superset/row_level_security/dao.py b/superset/row_level_security/dao.py
new file mode 100644
index 0000000000..1226e4d549
--- /dev/null
+++ b/superset/row_level_security/dao.py
@@ -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
diff --git a/superset/row_level_security/schemas.py b/superset/row_level_security/schemas.py
new file mode 100644
index 0000000000..63f9c8d6bc
--- /dev/null
+++ b/superset/row_level_security/schemas.py
@@ -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 depar [...]
+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
+    )
diff --git a/tests/integration_tests/security/row_level_security_tests.py b/tests/integration_tests/security/row_level_security_tests.py
index ebd95cae39..ef0870d32d 100644
--- a/tests/integration_tests/security/row_level_security_tests.py
+++ b/tests/integration_tests/security/row_level_security_tests.py
@@ -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,
 )
@@ -174,19 +180,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()
@@ -201,41 +206,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):
@@ -304,6 +307,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'\)")