diff --git a/frontend/libs/console/legacy-ce/src/lib/components/Services/Data/DataRouter.js b/frontend/libs/console/legacy-ce/src/lib/components/Services/Data/DataRouter.js index 6f100c54a7b..69763632baa 100644 --- a/frontend/libs/console/legacy-ce/src/lib/components/Services/Data/DataRouter.js +++ b/frontend/libs/console/legacy-ce/src/lib/components/Services/Data/DataRouter.js @@ -1,4 +1,4 @@ -import { IndexRedirect, IndexRoute, Route } from 'react-router'; +import { IndexRedirect, IndexRoute, Redirect, Route } from 'react-router'; import { SERVER_CONSOLE_MODE } from '../../../constants'; import globals from '../../../Globals'; @@ -39,6 +39,7 @@ import { ModifyTableContainer } from './TableModify/ModifyTableContainer'; import { LandingPageRoute as NativeQueries } from '../../../features/Data/LogicalModels/LandingPage/LandingPage'; import { TrackStoredProcedureRoute } from '../../../features/Data/LogicalModels/StoredProcedures/StoredProcedureWidget.route'; +import { LogicalModelPermissionsRoute } from '../../../features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissionsPage'; import { ManageFunction } from '../../../features/Data/ManageFunction/ManageFunction'; import { UpdateNativeQueryRoute, @@ -90,7 +91,16 @@ const makeDataRouter = ( path="native-query/:source/:name" component={UpdateNativeQueryRoute} /> - + + + + + + + { const push = usePushRoute(); @@ -149,8 +149,10 @@ export const LandingPage = ({ pathname }: { pathname: string }) => { { - push?.('/data/native-queries/logical-models/permissions'); + onEditClick={model => { + push?.( + `/data/native-queries/logical-models/${model.source.name}/${model.name}/permissions` + ); }} onRemoveClick={handleRemoveLogicalModel} /> diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListLogicalModels.stories.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListLogicalModels.stories.tsx index ec74f10ba05..fcbd680f0a6 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListLogicalModels.stories.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListLogicalModels.stories.tsx @@ -1,7 +1,7 @@ import { StoryObj, Meta } from '@storybook/react'; import { buildMetadata } from '../../mocks/metadata'; -import { extractModelsAndQueriesFromMetadata } from '../../utils'; import { ListLogicalModels } from './ListLogicalModels'; +import { extractModelsAndQueriesFromMetadata } from '../../../../hasura-metadata-api/selectors'; export default { component: ListLogicalModels, diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListLogicalModels.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListLogicalModels.tsx index a0c29434cfc..994efa63764 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListLogicalModels.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListLogicalModels.tsx @@ -39,8 +39,9 @@ export const ListLogicalModels = ({ header: 'Actions', cell: ({ cell, row }) => ( - {/* Re add once we implement Edit functionality */} - {/* onEditClick(row.original)}>Edit */} + onEditClick(row.original)}> + Edit Permissions + onRemoveClick(row.original)} diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListNativeQueries.stories.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListNativeQueries.stories.tsx index 58782444cf2..1c1be2c0f8f 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListNativeQueries.stories.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LandingPage/components/ListNativeQueries.stories.tsx @@ -2,7 +2,7 @@ import { StoryObj, Meta } from '@storybook/react'; import { ListNativeQueries } from './ListNativeQueries'; import { buildMetadata } from '../../mocks/metadata'; -import { extractModelsAndQueriesFromMetadata } from '../../utils'; +import { extractModelsAndQueriesFromMetadata } from '../../../../hasura-metadata-api/selectors'; export default { component: ListNativeQueries, diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissions.stories.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissions.stories.tsx new file mode 100644 index 00000000000..7abc20b6369 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissions.stories.tsx @@ -0,0 +1,173 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { LogicalModelPermissions } from './LogicalModelPermissions'; +import { comparators } from '../../../Permissions/PermissionsForm/components/RowPermissionsBuilder/components/__tests__/fixtures/comparators'; +import { userEvent, within } from '@storybook/testing-library'; +import { expect } from '@storybook/jest'; +import { ComponentProps } from 'react'; + +export default { + title: 'Features/Permissions/Form/Logical Model Permissions', + component: LogicalModelPermissions, +} as Meta; + +type Story = StoryObj; + +const noPermissionArgs: Partial< + ComponentProps +> = { + logicalModels: [ + { + source: { + name: 'default', + kind: 'postgres', + configuration: {}, + tables: [], + }, + fields: [ + { name: 'one', nullable: false, type: 'text' }, + { name: 'two', nullable: false, type: 'text' }, + ], + name: 'hello_world', + }, + ], + logicalModelName: 'hello_world', + comparators, +}; + +const existingPermissionArgs: Partial< + ComponentProps +> = { + logicalModels: [ + { + source: { + name: 'default', + kind: 'postgres', + configuration: {}, + tables: [], + }, + fields: [ + { name: 'one', nullable: false, type: 'text' }, + { name: 'two', nullable: false, type: 'text' }, + ], + name: 'hello_world', + select_permissions: [ + { + permission: { + columns: ['one'], + filter: { one: { _eq: 'eqone' } }, + }, + role: 'user', + }, + ], + }, + ], + logicalModelName: 'hello_world', + comparators, +}; + +export const NewPermission: Story = { + args: noPermissionArgs, + play: async ({ canvasElement }) => { + // Should allow writing new role name and then clicking select should open form + const canvas = within(canvasElement); + await userEvent.type(canvas.getByTestId('new-role-input'), 'reader'); + await userEvent.click(canvas.getByTestId('reader-select-permissions-cell')); + await expect(canvas.getByTestId('permissions-form')).toBeInTheDocument(); + }, +}; + +export const ExistingPermission: Story = { + args: existingPermissionArgs, + play: async ({ canvasElement, args: { onSave } }) => { + // Clicking select on existing permission should open form with correct role (user) and action (select) + const canvas = within(canvasElement); + await userEvent.click(canvas.getByTestId('user-select-permissions-cell')); + await expect(canvas.getByTestId('permissions-form')).toBeInTheDocument(); + await expect((await canvas.findByTestId('role-pill')).textContent).toBe( + 'user' + ); + await expect((await canvas.findByTestId('action-pill')).textContent).toBe( + 'select' + ); + + // It should send correct existing permission (`{"one":{"_eq":"eqone"}}`) when clicking save + await userEvent.click(canvas.getByTestId('save-permissions-button')); + await expect(onSave).toHaveBeenCalledWith({ + roleName: 'user', + filter: { + one: { + _eq: 'eqone', + }, + }, + columns: ['one'], + action: 'select', + isNew: false, + source: 'default', + }); + }, +}; + +export const UpdatingPermissions: Story = { + args: existingPermissionArgs, + play: async ({ canvasElement, args: { onSave } }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByTestId('user-select-permissions-cell')); + + // Clicking "Without any checks" should set permission to `{}` + await userEvent.click(canvas.getByTestId('without-filter')); + // Clicking `two` should select that column + await userEvent.click(canvas.getByTestId('column-two-checkbox')); + + await userEvent.click(canvas.getByTestId('save-permissions-button')); + await expect(onSave).toHaveBeenCalledWith({ + roleName: 'user', + filter: {}, + columns: ['one', 'two'], + action: 'select', + isNew: false, + source: 'default', + }); + }, +}; + +export const ToggleAllColumns: Story = { + args: existingPermissionArgs, + play: async ({ canvasElement, args: { onSave } }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByTestId('user-select-permissions-cell')); + + await userEvent.click(canvas.getByTestId('toggle-all-columns')); + + await userEvent.click(canvas.getByTestId('save-permissions-button')); + await expect(onSave).toHaveBeenCalledWith({ + roleName: 'user', + filter: { one: { _eq: 'eqone' } }, + columns: ['one', 'two'], + action: 'select', + isNew: false, + source: 'default', + }); + }, +}; + +export const UntoggleAllColumns: Story = { + args: existingPermissionArgs, + play: async ({ canvasElement, args: { onSave } }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByTestId('user-select-permissions-cell')); + + await userEvent.click(canvas.getByTestId('toggle-all-columns')); + await userEvent.click(canvas.getByTestId('toggle-all-columns')); + + await userEvent.click(canvas.getByTestId('save-permissions-button')); + await expect(onSave).toHaveBeenCalledWith({ + roleName: 'user', + filter: { one: { _eq: 'eqone' } }, + columns: [], + action: 'select', + isNew: false, + source: 'default', + }); + }, +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissions.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissions.tsx new file mode 100644 index 00000000000..3d9011e279f --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissions.tsx @@ -0,0 +1,125 @@ +import { useCallback } from 'react'; +import { + Comparators, + RowPermissionsInput, +} from '../../../Permissions/PermissionsForm/components/RowPermissionsBuilder/components'; +import { + LogicalModelWithPermissions, + OnDelete, + OnSave, + Permission, + RowSelectPermissionsType, +} from './components/types'; +import { + PermissionsTable, + PermissionsTableProps, +} from './components/PermissionsTable'; +import { PermissionsForm } from './components/PermissionsForm'; +import { usePermissionsFormContext } from './hooks/usePermissionForm'; +import { LogicalModelPermissionsFormProvider } from './components/LogicalModelPermissionsFormProvider'; + +export type LogicalModelPermissionsProps = { + logicalModelName: string; + logicalModels: LogicalModelWithPermissions[]; + onSave: OnSave; + onDelete: OnDelete; + comparators: Comparators; + isCreating?: boolean; + isRemoving?: boolean; +}; + +export type LogicalModelPermissionsState = { + permissions: Permission[]; + activePermission: number | null; + rowSelectPermissions: RowSelectPermissionsType; + columns: string[]; +}; + +export const LogicalModelPermissions = ({ + logicalModelName, + logicalModels, + onSave, + onDelete, + comparators, + isCreating, + isRemoving, +}: LogicalModelPermissionsProps) => { + const logicalModel = logicalModels.find( + model => model.name === logicalModelName + ); + + return ( + + + + ); +}; + +const PureLogicalModelPermissions = ({ + onSave: onSaveProp, + onDelete: onDeleteProp, + logicalModelName, + comparators, + logicalModels, + isCreating, + isRemoving, +}: LogicalModelPermissionsProps) => { + const { activePermission, permissions, setPermission } = + usePermissionsFormContext(); + const permission = + activePermission === null ? null : permissions[activePermission]; + const onSave = useCallback(async () => { + if (!permission) { + return; + } + await onSaveProp(permission); + }, [onSaveProp, permission]); + const onDelete = useCallback(async () => { + if (!permission) { + return; + } + await onDeleteProp(permission); + }, [onDeleteProp, permission]); + const allowedActions: PermissionsTableProps['allowedActions'] = ['select']; + return ( + + + + {permission && ( + { + setPermission(permission.roleName, permissionFilter); + }} + logicalModel={logicalModelName} + logicalModels={logicalModels} + permissions={permission.filter} + comparators={comparators} + /> + } + /> + )} + + + ); +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissionsPage.stories.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissionsPage.stories.tsx new file mode 100644 index 00000000000..4b2d4361fb9 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissionsPage.stories.tsx @@ -0,0 +1,106 @@ +import { Meta, StoryObj } from '@storybook/react'; +import { LogicalModelPermissionsPage } from './LogicalModelPermissionsPage'; +import { ReactQueryDecorator } from '../../../../storybook/decorators/react-query'; +import { RouteWrapper } from '../components/RouteWrapper'; +import { handlers, deleteHandlers } from './mocks'; + +const name = 'LogicalModel'; +const source = 'Postgres'; + +export default { + title: 'Features/Permissions/Form/Logical Model Permissions Page', + component: params => { + return ( + + + + ); + }, + decorators: [ReactQueryDecorator()], +} as Meta; + +type Story = StoryObj; + +export const SavePermission: Story = { + args: { + name, + source, + }, + // Play function deleted because it was failing in chromatic + // It works locally and in storybook, but not in chromatic + // TODO: Figure out the issue + // play: async ({ canvasElement }) => { + // const canvas = within(canvasElement); + // // Wait for both tabs and loading to be present because otherwise story fails in chromatic + // expect( + // await canvas.findByTestId( + // 'logical-model-permissions-tab', + // {}, + // { + // timeout: 10000, + // } + // ) + // ).toBeInTheDocument(); + // expect( + // await canvas.findByTestId('loading-logical-model-permissions') + // ).toBeInTheDocument(); + // await canvas.findByTestId('permissions-table'); + // await userEvent.click( + // await canvas.findByTestId('editor-select-permissions-cell') + // ); + // await userEvent.click(await canvas.findByTestId('save-permissions-button')); + // await expect( + // await canvas.findByText('Permissions saved successfully!') + // ).toBeInTheDocument(); + // }, + parameters: { + msw: handlers(), + consoleType: 'pro', + }, +}; + +export const DeletePermission: Story = { + args: { + name, + source, + }, + // Play function deleted because it was failing in chromatic + // It works locally and in storybook, but not in chromatic + // TODO: Figure out the issue + // play: async ({ canvasElement }) => { + // const canvas = within(canvasElement); + // // Wait for both tabs and loading to be present because otherwise story fails in chromatic + // expect( + // await canvas.findByTestId( + // 'logical-model-permissions-tab', + // {}, + // { + // timeout: 10000, + // } + // ) + // ); + // expect( + // await canvas.findByTestId('loading-logical-model-permissions') + // ).toBeInTheDocument(); + // await canvas.findByTestId('permissions-table'); + // await userEvent.click( + // await canvas.findByTestId('editor-select-permissions-cell') + // ); + // await userEvent.click( + // await canvas.findByTestId('delete-permissions-button') + // ); + // await expect( + // await canvas.findByText('Permissions successfully deleted!') + // ).toBeInTheDocument(); + // }, + parameters: { + msw: deleteHandlers(), + consoleType: 'pro', + }, +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissionsPage.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissionsPage.tsx new file mode 100644 index 00000000000..dde35a75f57 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/LogicalModelPermissionsPage.tsx @@ -0,0 +1,109 @@ +import { InjectedRouter, withRouter } from 'react-router'; +import { RouteWrapper } from '../components/RouteWrapper'; +import { Tabs } from '../../../../new-components/Tabs'; +import { LogicalModelPermissions } from './LogicalModelPermissions'; +import { useCreateLogicalModelsPermissions } from './hooks/useCreateLogicalModelsPermissions'; +import { useRemoveLogicalModelsPermissions } from './hooks/useRemoveLogicalModelsPermissions'; +import { useMetadata } from '../../../hasura-metadata-api'; +import { usePermissionComparators } from '../../../Permissions/PermissionsForm/components/RowPermissionsBuilder/hooks/usePermissionComparators'; +import Skeleton from 'react-loading-skeleton'; +import { extractModelsAndQueriesFromMetadata } from '../../../hasura-metadata-api/selectors'; + +export const LogicalModelPermissionsPage = ({ + source, + name, +}: { + source: string; + name: string; +}) => { + const { data, isLoading } = useMetadata(m => + extractModelsAndQueriesFromMetadata(m) + ); + const comparators = usePermissionComparators(); + const logicalModels = data?.models ?? []; + const logicalModel = logicalModels.find( + model => model.name === name && model.source.name === source + ); + const { create, isLoading: isCreating } = useCreateLogicalModelsPermissions({ + logicalModels, + source: logicalModel?.source, + }); + const { remove, isLoading: isRemoving } = useRemoveLogicalModelsPermissions({ + logicalModels, + source: logicalModel?.source, + }); + + return ( + + {isLoading ? ( + + + + ) : !logicalModel ? ( + + + Logical model with name {name} and driver {source} not found + + + ) : ( + { + create({ + logicalModelName: logicalModel?.name, + permission, + }); + }} + onDelete={async permission => { + remove({ + logicalModelName: logicalModel?.name, + permission, + }); + }} + isCreating={isCreating} + isRemoving={isRemoving} + comparators={comparators} + logicalModelName={logicalModel?.name} + logicalModels={logicalModels} + /> + )} + + ), + }, + ]} + /> + ); +}; + +export const LogicalModelPermissionsRoute = withRouter<{ + location: Location; + router: InjectedRouter; + params: { + source: string; + name: string; + }; +}>(({ params }) => { + return ( + + + + ); +}); diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/LogicalModelPermissionsFormProvider.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/LogicalModelPermissionsFormProvider.tsx new file mode 100644 index 00000000000..07530007665 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/LogicalModelPermissionsFormProvider.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from 'react'; +import { LogicalModelWithPermissions } from './types'; +import { useLogicalModelPermissionsForm } from '../hooks/usePermissionForm'; +import { FormProvider } from 'react-hook-form'; + +export function LogicalModelPermissionsFormProvider({ + children, + logicalModel, +}: { + children: ReactNode; + logicalModel: LogicalModelWithPermissions | undefined; +}) { + const methods = useLogicalModelPermissionsForm(logicalModel); + return {children}; +} diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionAccessCell.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionAccessCell.tsx new file mode 100644 index 00000000000..2abd7c2d0ee --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionAccessCell.tsx @@ -0,0 +1,36 @@ +import { PermissionsIcon } from '../../../../Permissions/PermissionsTable/components/PermissionsIcons'; + +interface PermissionAccessCellProps extends React.ComponentProps<'button'> { + access: 'fullAccess' | 'partialAccess' | 'noAccess'; + isEditable: boolean; + isCurrentEdit: boolean; +} + +export const PermissionAccessCell: React.FC = ({ + access, + isEditable, + isCurrentEdit, + ...rest +}) => { + if (!isEditable) { + return ( + + + + ); + } + + return ( + + + + + + ); +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionsForm.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionsForm.tsx new file mode 100644 index 00000000000..c8e3490fb80 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionsForm.tsx @@ -0,0 +1,191 @@ +import { ReactNode } from 'react'; +import { Button } from '../../../../../new-components/Button'; +import { IconTooltip } from '../../../../../new-components/Tooltip'; +import { usePermissionsFormContext } from '../hooks/usePermissionForm'; +import { Permission } from './types'; +import { Collapse } from '../../../../../new-components/deprecated'; +import { getEdForm } from '../../../../../components/Services/Data/utils'; +import { Badge } from '../../../../../new-components/Badge'; + +type PermissionsFormProps = { + permission: Permission; + onSave: () => void; + onDelete: () => void; + PermissionsInput: ReactNode; + isCreating?: boolean; + isRemoving?: boolean; +}; + +export const PermissionsForm = ({ + permission, + onDelete, + onSave, + PermissionsInput, + isCreating, + isRemoving, +}: PermissionsFormProps) => { + const { + unsetActivePermission, + rowSelectPermissions, + setRowSelectPermissions, + columns, + toggleColumn, + toggleAllColumns, + columnPermissionsStatus, + setPermission, + } = usePermissionsFormContext(); + return ( + { + e.preventDefault(); + onSave(); + }} + > + + + { + unsetActivePermission(); + }} + > + Close + + + Role: + + {permission.roleName} + + Action: + + {permission.action} + + + + + + + { + setRowSelectPermissions('without_filter'); + setPermission(permission.roleName, {}); + }} + /> + + + + + + + { + setRowSelectPermissions('with_custom_filter'); + }} + /> + + + + {rowSelectPermissions === 'with_custom_filter' && ( + + {PermissionsInput} + + )} + + + + + + + + + + Allow role {permission.roleName} to access{' '} + columns: + + + + {columns?.map(column => ( + + { + toggleColumn(permission, column); + }} + /> + {column} + + ))} + toggleAllColumns(permission)} + data-testid="toggle-all-columns" + > + Toggle All + + + + + + + + + Save Permissions + + + + Delete Permissions + + + + + ); +}; + +const NoChecksLabel = () => ( + Without any checks +); + +const CustomLabel = () => ( + + With custom check: + + +); diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionsRow.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionsRow.tsx new file mode 100644 index 00000000000..14eee43b6a7 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionsRow.tsx @@ -0,0 +1,66 @@ +import { forwardRef } from 'react'; +import { Action, Permission } from './types'; +import { PermissionsRowName } from './PermissionsRowName'; +import { PermissionAccessCell } from './PermissionAccessCell'; +import { usePermissionsFormContext } from '../hooks/usePermissionForm'; + +type PermissionsRowProps = { + permission: Permission; + index: number; + actions: string[]; + allowedActions: Action[]; +}; + +export const PermissionsRow = forwardRef( + ({ permission, actions, allowedActions, index }, ref) => { + const { + setActivePermission, + activePermission, + unsetActivePermission, + permissionAccess, + } = usePermissionsFormContext(); + return ( + + + {actions.map(actionName => { + const action = allowedActions.find( + allowedAction => allowedAction === actionName + ); + return ( + { + // Close form if user clicks cell for empty permission + if ( + activePermission !== index && + permission.isNew && + permission.roleName === '' + ) { + unsetActivePermission(); + } + // Focus on input so that user can enter new role name + if (permission.isNew && permission.roleName === '') { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + ref.current?.focus(); + } else { + setActivePermission(index); + } + }} + /> + ); + })} + + ); + } +); diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionsRowName.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionsRowName.tsx new file mode 100644 index 00000000000..45c3f1c8981 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionsRowName.tsx @@ -0,0 +1,40 @@ +import { forwardRef } from 'react'; +import { Role } from './types'; +import { usePermissionsFormContext } from '../hooks/usePermissionForm'; + +interface PermissionsRowNameProps { + roleName: Role['name']; + isNew: Role['isNew']; +} + +export const PermissionsRowName = forwardRef< + HTMLInputElement, + PermissionsRowNameProps +>(({ roleName, isNew }, inputRef) => { + const { setNewRoleName } = usePermissionsFormContext(); + if (isNew) { + return ( + + { + setNewRoleName(e.target.value); + }} + /> + + ); + } + + return ( + + + {roleName} + + + ); +}); diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionsTable.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionsTable.tsx new file mode 100644 index 00000000000..030589ef731 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/PermissionsTable.tsx @@ -0,0 +1,68 @@ +import { createRef } from 'react'; +import { PermissionsIcon } from '../../../../Permissions/PermissionsTable/components/PermissionsIcons'; +import { Action, Permission } from './types'; +import { PermissionsRow } from './PermissionsRow'; + +export type PermissionsTableProps = { + allowedActions: Action[]; + permissions: Permission[]; +}; + +export const PermissionsTable = ({ + allowedActions, + permissions, +}: PermissionsTableProps) => { + const actions = ['insert', 'select', 'update', 'delete']; + const inputRef = createRef(); + return ( + + + + + - full access + + + + - no access + + + + - partial access + + + + + + + + ROLE + + {actions.map(action => ( + + {action.toUpperCase()} + + ))} + + + + + {permissions.map((permission, index) => ( + // Using index as key instead of role.name because role.name is editable + + ))} + + + + + ); +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/types.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/types.ts new file mode 100644 index 00000000000..f28580ae981 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/components/types.ts @@ -0,0 +1,44 @@ +import { LogicalModel, Source } from '../../../../hasura-metadata-types'; + +export type LogicalModelWithSourceName = LogicalModel & { + source: Source; +}; + +export type Permission = { + roleName: string; + source: string; + action: Action; + filter: Record; + columns: string[]; + isNew: boolean; +}; + +export type PermissionId = { + source: Permission['source']; + name: Permission['roleName']; +}; + +export type Action = 'select' | 'insert' | 'update' | 'delete'; + +export type LogicalModelWithPermissions = LogicalModelWithSourceName & { + select_permissions?: { + role: string; + permission: { + columns: string[]; + filter: Record; + }; + }[]; +}; + +export type Role = { + name: string; + isNew?: boolean; +}; + +export type AccessType = 'fullAccess' | 'noAccess' | 'partialAccess'; + +export type OnSave = (permission: Permission) => Promise; + +export type OnDelete = (permission: Permission) => Promise; + +export type RowSelectPermissionsType = 'with_custom_filter' | 'without_filter'; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/useCreateLogicalModelsPermissions.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/useCreateLogicalModelsPermissions.ts new file mode 100644 index 00000000000..33412e84326 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/useCreateLogicalModelsPermissions.ts @@ -0,0 +1,86 @@ +import { useCallback } from 'react'; +import { useMetadataMigration } from '../../../../MetadataAPI'; +import { exportMetadata } from '../../../../DataSource'; +import { useHttpClient } from '../../../../Network'; +import { getCreateLogicalModelBody } from './utils/getCreateLogicalModelBody'; +import { LogicalModel, Source } from '../../../../hasura-metadata-types'; +import { useQueryClient } from 'react-query'; +import { useFireNotification } from '../../../../../new-components/Notifications/index'; +import { METADATA_QUERY_KEY } from '../../../../hasura-metadata-api/useMetadata'; +import { errorTransform } from './utils/errorTransform'; + +const useCreateLogicalModelsPermissions = ({ + logicalModels, + source, +}: { + logicalModels: LogicalModel[]; + source: Source | undefined; +}) => { + const mutate = useMetadataMigration({ + errorTransform, + }); + const { fireNotification } = useFireNotification(); + const httpClient = useHttpClient(); + const queryClient = useQueryClient(); + + const create = useCallback( + async ({ permission, logicalModelName, onSuccess }) => { + const { resource_version } = await exportMetadata({ + httpClient, + }); + if (!source) return; + + const body = getCreateLogicalModelBody({ + permission, + logicalModelName, + logicalModels, + source, + }); + + try { + await mutate.mutateAsync( + { + query: { type: 'bulk', args: body, resource_version }, + }, + { + onSuccess: async () => { + fireNotification({ + type: 'success', + title: 'Success!', + message: 'Permissions saved successfully!', + }); + }, + onError: err => { + fireNotification({ + type: 'error', + title: 'Error!', + message: + err?.message ?? + 'Something went wrong while saving permissions', + }); + }, + onSettled: async () => { + await queryClient.invalidateQueries([METADATA_QUERY_KEY]); + onSuccess?.(); + }, + } + ); + } catch (error: any) { + fireNotification({ + type: 'error', + title: 'Error!', + message: + error?.message ?? 'Something went wrong while saving permissions', + }); + } + }, + [logicalModels, source] + ); + + return { + create, + ...mutate, + }; +}; + +export { useCreateLogicalModelsPermissions }; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/usePermissionForm.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/usePermissionForm.ts new file mode 100644 index 00000000000..b6d434c2341 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/usePermissionForm.ts @@ -0,0 +1,173 @@ +import { useForm, useFormContext } from 'react-hook-form'; +import { LogicalModelPermissionsState } from '../LogicalModelPermissions'; +import { + AccessType, + Action, + LogicalModelWithPermissions, + Permission, + RowSelectPermissionsType, +} from '../components/types'; +import isEmpty from 'lodash/isEmpty'; +import { permissionColumnAccess, permissionRowAccess } from '../utils'; + +export function useLogicalModelPermissionsForm( + logicalModel: LogicalModelWithPermissions | undefined +) { + const defaultPermissions: Permission[] = [ + ...(logicalModel?.select_permissions?.map(permission => ({ + roleName: permission.role, + filter: permission.permission.filter, + columns: permission.permission.columns, + action: 'select' as const, + isNew: false, + source: logicalModel?.source.name, + })) ?? []), + { + roleName: '', + filter: {}, + columns: [], + action: 'select' as const, + isNew: true, + source: logicalModel?.source.name || '', + }, + ]; + const methods = useForm({ + defaultValues: { + activePermission: null, + rowSelectPermissions: 'without_filter', + permissions: defaultPermissions, + columns: logicalModel?.fields?.map(field => field.name) ?? [], + }, + }); + return methods; +} + +export function usePermissionsFormContext() { + const { setValue, watch } = useFormContext(); + return { + permissions: watch('permissions'), + setPermission: (roleName: string, filter: Record) => { + const permissions = watch('permissions'); + setValue( + 'permissions', + permissions.map(permission => { + if (permission.roleName === roleName) { + return { + ...permission, + filter, + }; + } + return permission; + }) + ); + }, + rowSelectPermissions: watch('rowSelectPermissions'), + setRowSelectPermissions: (rowSelectPermissions: RowSelectPermissionsType) => + setValue('rowSelectPermissions', rowSelectPermissions), + columns: watch('columns'), + toggleColumn: (permission: Permission, column: string) => { + const permissions = watch('permissions'); + setValue( + 'permissions', + permissions.map(p => { + if ( + p.roleName === permission.roleName && + p.source === permission.source + ) { + const columns = p.columns.includes(column) + ? p.columns.filter(c => c !== column) + : [...p.columns, column]; + return { + ...p, + columns, + }; + } + return p; + }) + ); + }, + toggleAllColumns: (permission: Permission) => { + const permissions = watch('permissions'); + setValue( + 'permissions', + permissions.map(p => { + if ( + p.roleName === permission.roleName && + p.source === permission.source + ) { + const columns = + p.columns.length === watch('columns').length + ? [] + : watch('columns'); + return { + ...p, + columns, + }; + } + return p; + }) + ); + }, + columnPermissionsStatus: ( + permission: Permission + ): '' | 'No columns' | 'All columns' | 'Partial columns' => { + if (!permission) { + return ''; + } + if (permission.columns.length === 0) { + return 'No columns'; + } + if (permission.columns.length === watch('columns').length) { + return 'All columns'; + } + return 'Partial columns'; + }, + setActivePermission: (index: number) => { + const permission = watch('permissions')[index]; + const rowSelectPermissions: RowSelectPermissionsType = + permission.isNew || isEmpty(permission.filter) + ? 'without_filter' + : 'with_custom_filter'; + + setValue('activePermission', index); + setValue('rowSelectPermissions', rowSelectPermissions); + }, + unsetActivePermission: () => setValue('activePermission', null), + activePermission: watch('activePermission'), + setNewRoleName: (roleName: string) => { + const permissions = watch('permissions'); + setValue( + 'permissions', + permissions.map(permission => { + if (permission.isNew) { + return { + ...permission, + roleName, + }; + } + return permission; + }) + ); + }, + /** + * Returns the access type of the permission for the given action + */ + permissionAccess: ( + action: Action | undefined, + permission: Permission + ): AccessType => { + if (action !== 'select') { + return 'noAccess'; + } + const rowAccess = permissionRowAccess(permission); + const columnAccess = permissionColumnAccess(permission, watch('columns')); + if (rowAccess === 'noAccess' && columnAccess === 'noAccess') { + return 'noAccess'; + } + if (rowAccess === 'fullAccess' && columnAccess === 'fullAccess') { + return 'fullAccess'; + } + return 'partialAccess'; + }, + }; +} diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/useRemoveLogicalModelsPermissions.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/useRemoveLogicalModelsPermissions.ts new file mode 100644 index 00000000000..2e292a69100 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/useRemoveLogicalModelsPermissions.ts @@ -0,0 +1,94 @@ +import { useCallback } from 'react'; +import { useMetadataMigration } from '../../../../MetadataAPI'; +import { exportMetadata } from '../../../../DataSource'; +import { useHttpClient } from '../../../../Network'; +import { LogicalModel, Source } from '../../../../hasura-metadata-types'; +import { useQueryClient } from 'react-query'; +import { useFireNotification } from '../../../../../new-components/Notifications/index'; +import { METADATA_QUERY_KEY } from '../../../../hasura-metadata-api/useMetadata'; +import { errorTransform } from './utils/errorTransform'; +import { Permission } from '../components/types'; +import { getDeleteLogicalModelBody } from './utils/getDeleteLogicalModelBody'; + +const useRemoveLogicalModelsPermissions = ({ + logicalModels, + source, +}: { + logicalModels: LogicalModel[]; + source: Source | undefined; +}) => { + const mutate = useMetadataMigration({ + errorTransform, + }); + const { fireNotification } = useFireNotification(); + const httpClient = useHttpClient(); + const queryClient = useQueryClient(); + + const remove = useCallback( + async ({ + permission, + logicalModelName, + onSuccess, + }: { + permission: Permission; + logicalModelName: string; + onSuccess?: () => void; + }) => { + const { resource_version } = await exportMetadata({ + httpClient, + }); + if (!source) return; + + const body = getDeleteLogicalModelBody({ + permission, + logicalModelName, + source, + }); + + try { + await mutate.mutateAsync( + { + query: { type: 'bulk', args: body, resource_version }, + }, + { + onSuccess: async () => { + fireNotification({ + type: 'success', + title: 'Success!', + message: 'Permissions successfully deleted!', + }); + }, + onError: err => { + fireNotification({ + type: 'error', + title: 'Error!', + message: + err?.message ?? + 'Something went wrong while deleting permissions', + }); + }, + onSettled: async () => { + await queryClient.invalidateQueries([METADATA_QUERY_KEY]); + onSuccess?.(); + }, + } + ); + } catch (error: any) { + fireNotification({ + type: 'error', + title: 'Error!', + message: + error?.message ?? 'Something went wrong while saving permissions', + }); + } + }, + [source] + ); + + return { + remove, + ...mutate, + }; +}; + +export { useRemoveLogicalModelsPermissions }; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/errorTransform.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/errorTransform.ts new file mode 100644 index 00000000000..a2485118520 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/errorTransform.ts @@ -0,0 +1,16 @@ +export function errorTransform(error: any) { + const err = error as Record; + + let message = ''; + let name = `Error code: ${err.code}`; + + if ('internal' in err) { + name = err?.internal?.[0]?.name ?? name; + message = err?.internal?.[0]?.reason ?? 'Internal error'; + } else message = err.error; + + return { + name, + message, + }; +} diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/getCreateLogicalModelBody.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/getCreateLogicalModelBody.ts new file mode 100644 index 00000000000..07fd295c78e --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/getCreateLogicalModelBody.ts @@ -0,0 +1,73 @@ +import { getPermissionValues } from './getPermissionValues'; +import { LogicalModel, Source } from '../../../../../hasura-metadata-types'; +import { Permission } from '../../components/types'; +import { mapPostgresToPg } from '.'; + +export interface CreateLogicalModalBodyArgs { + logicalModels: LogicalModel[]; + logicalModelName: string; + permission: Permission; + source: Source; +} + +type PermissionArgsType = { + name: string; + role: string; + permission?: Record; + source: string; +}; +type PermissionBodyType = { + type: string; + args: PermissionArgsType; +}; + +const doesRoleExist = (logicalModel: LogicalModel, roleName: string) => { + const permissionKeys = ['select_permissions'] as ['select_permissions']; + return permissionKeys.some( + key => + Array.isArray(logicalModel[key]) && + logicalModel[key]?.some(permission => permission.role === roleName) + ); +}; + +export const getCreateLogicalModelBody = ({ + logicalModelName, + permission, + logicalModels, + source, +}: CreateLogicalModalBodyArgs): PermissionBodyType[] => { + const permissionValues = getPermissionValues(permission); + const args = [ + { + type: `${mapPostgresToPg(source.kind)}_create_logical_model_${ + permission.action + }_permission`, + args: { + name: logicalModelName, + role: permission.roleName, + permission: permissionValues, + source: source.name, + }, + }, + ]; + + const permissionAlreadyExists = logicalModels.find((model: LogicalModel) => + doesRoleExist(model, permission.roleName) + ); + + if (permissionAlreadyExists) { + args.unshift({ + type: `${mapPostgresToPg(source.kind)}_drop_logical_model_${ + permission.action + }_permission`, + args: { + permission: {}, + name: logicalModelName, + role: permission.roleName, + source: source.name, + }, + }); + } + + return args; +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/getDeleteLogicalModelBody.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/getDeleteLogicalModelBody.ts new file mode 100644 index 00000000000..80a0c36aaab --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/getDeleteLogicalModelBody.ts @@ -0,0 +1,40 @@ +import { mapPostgresToPg } from '.'; +import { Source } from '../../../../../hasura-metadata-types'; +import { Permission } from '../../components/types'; +export interface DeleteLogicalModalBodyArgs { + logicalModelName: string; + permission: Permission; + source: Source; +} + +type PermissionArgsType = { + name: string; + role: string; + source: string; +}; + +type PermissionBodyType = { + type: string; + args: PermissionArgsType; +}; + +export const getDeleteLogicalModelBody = ({ + logicalModelName, + permission, + source, +}: DeleteLogicalModalBodyArgs): PermissionBodyType[] => { + const args = [ + { + type: `${mapPostgresToPg(source.kind)}_drop_logical_model_${ + permission.action + }_permission`, + args: { + name: logicalModelName, + role: permission.roleName, + source: source.name, + }, + }, + ]; + + return args; +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/getPermissionValues.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/getPermissionValues.ts new file mode 100644 index 00000000000..35173d4254f --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/getPermissionValues.ts @@ -0,0 +1,36 @@ +type GetPermissionValuesInputType = { + roleName: string; + filter?: Record; + check?: Record; + columns?: string[]; + action: string; + isNew: boolean; + source: string; +}; + +type GetPermissionValuesOutputType = { + filter?: Record; + check?: Record; + columns?: string[]; +}; + +export const getPermissionValues = ( + obj: GetPermissionValuesInputType +): GetPermissionValuesOutputType => { + const { filter, check, columns } = obj; + const result: GetPermissionValuesOutputType = {}; + + if (filter !== undefined) { + result.filter = filter; + } + + if (check !== undefined) { + result.check = check; + } + + if (columns !== undefined) { + result.columns = columns; + } + + return result; +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/index.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/index.ts new file mode 100644 index 00000000000..47352fbc16c --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/hooks/utils/index.ts @@ -0,0 +1,3 @@ +export function mapPostgresToPg(kind: string) { + return kind === 'postgres' ? 'pg' : kind; +} diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/config.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/config.ts new file mode 100644 index 00000000000..dea92f8e176 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/config.ts @@ -0,0 +1,34 @@ +export default { + version: '12345', + is_function_permissions_inferred: true, + is_remote_schema_permissions_enabled: false, + is_admin_secret_set: true, + is_auth_hook_set: false, + is_jwt_set: false, + jwt: [], + is_allow_list_enabled: false, + live_queries: { batch_size: 100, refetch_delay: 1 }, + streaming_queries: { batch_size: 100, refetch_delay: 1 }, + console_assets_dir: null, + experimental_features: ['naming_convention'], + is_prometheus_metrics_enabled: false, + default_naming_convention: 'hasura-default', + feature_flags: [ + { + name: 'stored-procedures', + description: 'Expose stored procedures support', + enabled: false, + }, + { + name: 'native-query-interface', + description: + 'Expose custom views, permissions and advanced SQL functionality via custom queries', + enabled: true, + }, + { + name: 'test-flag', + description: 'Testing feature flag integration', + enabled: false, + }, + ], +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/delete.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/delete.ts new file mode 100644 index 00000000000..c24948c80f4 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/delete.ts @@ -0,0 +1,23 @@ +const payload = { + type: 'bulk', + args: [ + { + type: 'pg_drop_logical_model_select_permission', + args: { + name: 'LogicalModel', + role: 'editor', + source: 'Postgres', + }, + }, + ], + resource_version: 222, +}; + +const response = { + message: 'success', +}; + +export default { + payload, + response, +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/index.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/index.ts new file mode 100644 index 00000000000..ca1b6f7a150 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/index.ts @@ -0,0 +1,125 @@ +import { rest } from 'msw'; +import config from './config'; +import metadata from './metadata'; +import save from './save'; +import deleteMocks from './delete'; + +export const handlers = () => [ + rest.get('http://localhost:8080/v1alpha1/config', async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(config)); + }), + rest.post('http://localhost:8080/v1/metadata', async (req, res, ctx) => { + const reqBody = await req.json<{ + type: string; + args: any; + }>(); + if (reqBody.type === 'export_metadata') { + return res(ctx.status(200), ctx.json({ metadata })); + } + if ( + reqBody.type === 'bulk' && + reqBody.args.length === 2 && + reqBody.args[0].type === 'pg_drop_logical_model_select_permission' && + reqBody.args[1].type === 'pg_create_logical_model_select_permission' + ) { + return res(ctx.status(200), ctx.json(save.response)); + } + if ( + reqBody.type === 'bulk' && + reqBody.args.length === 1 && + reqBody.args[0].type === 'pg_drop_logical_model_select_permission' + ) { + return res(ctx.status(200), ctx.json(deleteMocks.response)); + } + }), + rest.get('http://localhost:8080/v1/entitlement', async (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + metadata_db_id: '58a9e616-5fe9-4277-95fa-e27f9d45e177', + status: 'none', + }) + ); + }), +]; + +export const deleteHandlers = () => { + let hasDeleted = false; + return [ + rest.get('http://localhost:8080/v1alpha1/config', async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(config)); + }), + rest.post('http://localhost:8080/v1/metadata', async (req, res, ctx) => { + const reqBody = await req.json<{ + type: string; + args: any; + }>(); + if (reqBody.type === 'export_metadata' && hasDeleted) { + return res( + ctx.status(200), + ctx.json({ + metadata: { + ...metadata, + sources: [ + ...metadata.sources.map(source => { + if (source.name !== 'Postgres') { + return source; + } + return { + ...source, + logical_models: source.logical_models.map(logical_model => { + if (logical_model.name !== 'LogicalModel') { + return logical_model; + } + return { + fields: logical_model.fields, + name: logical_model.name, + // Omit select_permissions to simulate deletion + }; + }), + }; + }), + ], + }, + }) + ); + } + if (reqBody.type === 'export_metadata') { + return res( + ctx.status(200), + ctx.json({ + metadata, + }) + ); + } + if (reqBody.type === 'export_metadata') { + return res(ctx.status(200), ctx.json({ metadata })); + } + if ( + reqBody.type === 'bulk' && + reqBody.args.length === 2 && + reqBody.args[0].type === 'pg_drop_logical_model_select_permission' && + reqBody.args[1].type === 'pg_create_logical_model_select_permission' + ) { + return res(ctx.status(200), ctx.json(save.response)); + } + if ( + reqBody.type === 'bulk' && + reqBody.args.length === 1 && + reqBody.args[0].type === 'pg_drop_logical_model_select_permission' + ) { + hasDeleted = true; + return res(ctx.status(200), ctx.json(deleteMocks.response)); + } + }), + rest.get('http://localhost:8080/v1/entitlement', async (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + metadata_db_id: '58a9e616-5fe9-4277-95fa-e27f9d45e177', + status: 'none', + }) + ); + }), + ]; +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/metadata.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/metadata.ts new file mode 100644 index 00000000000..9d6ee01a973 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/metadata.ts @@ -0,0 +1,175 @@ +export default { + version: 3, + sources: [ + { + name: 'MS', + kind: 'mssql', + tables: [], + logical_models: [ + { + fields: [], + name: 'Model', + }, + ], + configuration: { + connection_info: { + connection_string: + 'Driver={ODBC Driver 18 for SQL Server};Server=tcp:host.docker.internal,1433;Database=tempdb;Uid=sa;Pwd=Password!;Encrypt=optional', + pool_settings: { + idle_timeout: 5, + max_connections: null, + total_max_connections: null, + }, + }, + }, + }, + { + name: 'Postgres', + kind: 'postgres', + tables: [ + { + table: { + name: 'Album', + schema: 'public', + }, + object_relationships: [ + { + name: 'Album_Artist', + using: { + foreign_key_constraint_on: 'ArtistId', + }, + }, + { + name: 'D', + using: { + manual_configuration: { + column_mapping: { + ArtistId: 'ArtistId', + }, + insertion_order: null, + remote_table: { + name: 'Artist', + schema: 'public', + }, + }, + }, + }, + ], + remote_relationships: [ + { + definition: { + to_source: { + field_mapping: { + ArtistId: 'ArtistId', + }, + relationship_type: 'object', + source: 'Postgres', + table: { + name: 'Artist', + schema: 'public', + }, + }, + }, + name: 'A', + }, + { + definition: { + to_source: { + field_mapping: { + ArtistId: 'ArtistId', + }, + relationship_type: 'object', + source: 'Postgres', + table: { + name: 'Artist', + schema: 'public', + }, + }, + }, + name: 'B', + }, + { + definition: { + to_source: { + field_mapping: { + ArtistId: 'ArtistId', + }, + relationship_type: 'object', + source: 'Postgres', + table: { + name: 'Artist', + schema: 'public', + }, + }, + }, + name: 'C', + }, + ], + }, + { + table: { + name: 'Artist', + schema: 'public', + }, + }, + ], + logical_models: [ + { + fields: [ + { + name: 'id', + nullable: true, + type: 'integer', + }, + { + name: 'name', + nullable: true, + type: 'text', + }, + ], + name: 'LogicalModel', + select_permissions: [ + { + permission: { + columns: ['id'], + filter: {}, + }, + role: 'editor', + }, + ], + }, + { + fields: [], + name: 'M', + select_permissions: [ + { + permission: { + columns: [], + filter: { + _or: [{}], + }, + }, + role: 'M', + }, + { + permission: { + columns: [], + filter: { + _and: [{}], + }, + }, + role: 'reader', + }, + ], + }, + ], + configuration: { + connection_info: { + database_url: 'postgres://postgres:pass@postgres:5432/chinook', + isolation_level: 'read-committed', + use_prepared_statements: false, + }, + }, + }, + ], +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/save.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/save.ts new file mode 100644 index 00000000000..ff4aa7da885 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/mocks/save.ts @@ -0,0 +1,38 @@ +const payload = { + type: 'bulk', + args: [ + { + type: 'pg_drop_logical_model_select_permission', + args: { + permission: {}, + name: 'LogicalModel', + role: 'editor', + source: 'Postgres', + }, + }, + { + type: 'pg_create_logical_model_select_permission', + args: { + name: 'LogicalModel', + role: 'editor', + permission: { + filter: {}, + columns: [], + }, + source: 'Postgres', + }, + }, + ], + resource_version: 221, +}; + +const response = [ + { + message: 'success', + }, + { + message: 'success', + }, +]; + +export default { payload, response }; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/utils.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/utils.ts new file mode 100644 index 00000000000..42ab26272ba --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/LogicalModelPermissions/utils.ts @@ -0,0 +1,23 @@ +import isEmpty from 'lodash/isEmpty'; +import { AccessType, Permission } from './components/types'; + +export function permissionRowAccess(permission: Permission): AccessType { + if (!permission.filter || isEmpty(permission.filter)) { + return 'noAccess'; + } else { + return 'fullAccess'; + } +} + +export function permissionColumnAccess( + permission: Permission, + selectedColumns: string[] +): AccessType { + if (permission.columns.length === 0) { + return 'noAccess'; + } else if (permission.columns.length > selectedColumns.length) { + return 'partialAccess'; + } else { + return 'fullAccess'; + } +} diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/components/RouteWrapper.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/components/RouteWrapper.tsx index d4cc51e4bbf..6e30eedaa50 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/components/RouteWrapper.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/components/RouteWrapper.tsx @@ -1,5 +1,5 @@ import startCase from 'lodash/startCase'; -import { ReactNode } from 'react'; +import React, { ReactNode } from 'react'; import Skeleton from 'react-loading-skeleton'; import { useServerConfig } from '../../../../hooks'; import { Breadcrumbs } from '../../../../new-components/Breadcrumbs'; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/constants.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/constants.ts index 193945f7e1b..08de5846309 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/constants.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/constants.ts @@ -49,4 +49,9 @@ export const NATIVE_QUERY_ROUTES = { title: 'Track Stored Procedure', subtitle: 'Expose your stored SQL procedures via the GraphQL API', }, + '/data/native-queries/logical-models/{{source}}/{{name}}/permissions': { + title: 'Logical Models Permissions', + subtitle: + 'Add permissions to your Logical Models to control access to your data', + }, }; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/utils.ts b/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/utils.ts deleted file mode 100644 index 88e0565ebf9..00000000000 --- a/frontend/libs/console/legacy-ce/src/lib/features/Data/LogicalModels/utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Metadata } from '../../hasura-metadata-types'; -import { LogicalModelWithSource, NativeQueryWithSource } from './types'; - -export const extractModelsAndQueriesFromMetadata = ( - m: Metadata -): { queries: NativeQueryWithSource[]; models: LogicalModelWithSource[] } => { - const sources = m.metadata.sources; - let models: LogicalModelWithSource[] = []; - let queries: NativeQueryWithSource[] = []; - - sources.forEach(s => { - if (s.logical_models && s.logical_models.length > 0) { - models = [...models, ...s.logical_models.map(m => ({ ...m, source: s }))]; - } - - if (s.native_queries && s.native_queries.length > 0) { - queries = [ - ...queries, - ...s.native_queries.map(q => ({ ...q, source: s })), - ]; - } - }); - - return { - models, - queries, - }; -}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/RowPermissionBuilder.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/RowPermissionBuilder.tsx index f5b2fa97d8e..be8e3a5163f 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/RowPermissionBuilder.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/RowPermissionBuilder.tsx @@ -41,6 +41,8 @@ export const RowPermissionBuilder = ({ }} table={table} tables={tables} + logicalModel={undefined} + logicalModels={[]} permissions={value} comparators={comparators} /> diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/Comparator.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/Comparator.tsx index 516ba88af0f..44091f6f4f5 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/Comparator.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/Comparator.tsx @@ -1,7 +1,9 @@ import { useContext } from 'react'; import { rowPermissionsContext } from './RowPermissionsProvider'; import { useOperators } from './utils/comparatorsFromSchema'; -import Select from 'react-select'; +import Select, { components } from 'react-select'; +import { FiChevronDown } from 'react-icons/fi'; +import clsx from 'clsx'; export const Comparator = ({ comparator, @@ -21,7 +23,20 @@ export const Comparator = ({ inputId={`${comparatorLevelId}-select-value`} isSearchable aria-label={comparatorLevelId} - components={{ DropdownIndicator: null }} + components={{ + DropdownIndicator: props => { + const { className } = props; + return ( + + + + ); + }, + IndicatorSeparator: () => null, + }} options={operators.map(o => ({ value: o.name, label: o.name, diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/EntryType.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/EntryType.tsx index 451bb80ba14..42fd25397ab 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/EntryType.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/EntryType.tsx @@ -5,6 +5,7 @@ import { isColumnComparator } from './utils'; import { ColumnComparatorEntry } from './EntryTypes/ColumnComparatorEntry'; import { useOperators } from './utils/comparatorsFromSchema'; import { ValueInput } from './ValueInput'; +import { useForbiddenFeatures } from './ForbiddenFeaturesProvider'; export const EntryType = ({ k, @@ -17,6 +18,7 @@ export const EntryType = ({ }) => { const operators = useOperators({ path }); const operator = operators.find(o => o.name === k); + const { hasFeature } = useForbiddenFeatures(); if (isColumnComparator(k)) { return ; } @@ -27,6 +29,9 @@ export const EntryType = ({ return ; } if (k === '_exists') { + if (!hasFeature('exists')) { + return null; + } return ; } if ( diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/EntryTypes/ColumnComparatorEntry.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/EntryTypes/ColumnComparatorEntry.tsx index 66b5faca5e2..70b524d899d 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/EntryTypes/ColumnComparatorEntry.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/EntryTypes/ColumnComparatorEntry.tsx @@ -3,8 +3,8 @@ import { isComparator } from '../utils/helpers'; import { tableContext } from '../TableProvider'; import { typesContext } from '../TypesProvider'; import { rowPermissionsContext } from '../RowPermissionsProvider'; -import { areTablesEqual } from '../../../../../../hasura-metadata-api'; import { createWrapper } from './utils'; +import { rootTableContext } from '../RootTableProvider'; export function ColumnComparatorEntry({ k, @@ -107,9 +107,9 @@ function RootColumnsSelect({ v: any; path: string[]; }) { - const { table, tables, setValue } = useContext(rowPermissionsContext); + const { setValue } = useContext(rowPermissionsContext); const value = v.find((v: any) => v !== '$'); - const rootTable = tables.find(t => areTablesEqual(t.table, table)); + const { rootTable } = useContext(rootTableContext); const testId = `${path.join('.')}-root-column-comparator-entry`; return ( boolean; +}>({ + forbidden: [], + hasFeature: () => { + return true; + }, +}); + +export function useForbiddenFeatures() { + return useContext(forbiddenFeaturesContext); +} + +export function ForbiddenFeaturesProvider({ + forbidden = [], + children, +}: { + forbidden: Feature[] | undefined; + children: ReactNode; +}) { + const hasFeature = useCallback( + function hasFeature(feature: Feature) { + return !forbidden.includes(feature); + }, + [forbidden] + ); + return ( + + {children} + + ); +} diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/JsonEditor.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/JsonEditor.tsx index 08351cb3688..80b033227d0 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/JsonEditor.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/JsonEditor.tsx @@ -3,11 +3,11 @@ import { useContext } from 'react'; import { getTableDisplayName } from '../../../../../DatabaseRelationships'; import { rowPermissionsContext } from './RowPermissionsProvider'; +import { rootTableContext } from './RootTableProvider'; export const JsonEditor = () => { - const { permissions, table, setPermissions } = useContext( - rowPermissionsContext - ); + const { permissions, setPermissions } = useContext(rowPermissionsContext); + const { table } = useContext(rootTableContext); return ( ; + +export const Basic: Story = { + args: { + table: undefined, + tables: [], + onPermissionsChange: action('onPermissionsChange'), + logicalModels: [ + { + fields: [ + { name: 'one', nullable: false, type: 'text' }, + { name: 'two', nullable: false, type: 'text' }, + ], + name: 'hello_world', + source: { + name: 'default', + kind: 'postgres', + configuration: {}, + tables: [], + }, + }, + { + fields: [ + { name: 'a', nullable: false, type: 'text' }, + { name: 'b', nullable: false, type: 'text' }, + ], + name: 'logical_model', + source: { + name: 'default', + kind: 'postgres', + configuration: {}, + tables: [], + }, + }, + ], + logicalModel: 'hello_world', + comparators, + permissions: { one: { _eq: 'eqone' } }, + }, +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/Operator.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/Operator.tsx index d1680a27035..97e6bd14549 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/Operator.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/Operator.tsx @@ -3,6 +3,8 @@ import { useContext } from 'react'; import { rowPermissionsContext } from './RowPermissionsProvider'; import { tableContext } from './TableProvider'; import { PermissionType } from './types'; +import { logicalModelContext } from './RootLogicalModelProvider'; +import { useForbiddenFeatures } from './ForbiddenFeaturesProvider'; export const Operator = ({ operator, @@ -15,12 +17,14 @@ export const Operator = ({ }) => { const { operators, setKey } = useContext(rowPermissionsContext); const { columns, table, relationships } = useContext(tableContext); + const { rootLogicalModel } = useContext(logicalModelContext); const parent = path[path.length - 1]; const operatorLevelId = `${path?.join('.')}-operator`; + const { hasFeature } = useForbiddenFeatures(); return ( { @@ -55,7 +59,20 @@ export const Operator = ({ ))} ) : null} - {operators.exist?.items.length ? ( + {rootLogicalModel?.fields.length ? ( + + {rootLogicalModel?.fields.map((field, index) => ( + + {field.name} + + ))} + + ) : null} + {hasFeature('exists') && operators.exist?.items.length ? ( {operators.exist.items.map((item, index) => ( diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RootLogicalModelProvider.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RootLogicalModelProvider.tsx new file mode 100644 index 00000000000..e9d284c1305 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RootLogicalModelProvider.tsx @@ -0,0 +1,35 @@ +import { createContext } from 'react'; +import { LogicalModel } from '../../../../../hasura-metadata-types'; + +type LogicalModelState = { + rootLogicalModel: LogicalModel | undefined; + logicalModels: LogicalModel[]; + logicalModel: LogicalModel['name'] | undefined; +}; + +export const logicalModelContext = createContext({ + logicalModel: '', + logicalModels: [], + rootLogicalModel: undefined, +}); + +export const RootLogicalModelProvider = ({ + children, + logicalModel, + logicalModels, +}: Omit & { + children?: React.ReactNode | undefined; +}) => { + const rootLogicalModel = logicalModels.find(t => t.name === logicalModel); + return ( + + {children} + + ); +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RootTableProvider.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RootTableProvider.tsx new file mode 100644 index 00000000000..0404ba7cc00 --- /dev/null +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RootTableProvider.tsx @@ -0,0 +1,45 @@ +import { createContext } from 'react'; +import { Table } from '../../../../../hasura-metadata-types'; +import { areTablesEqual } from '../../../../../hasura-metadata-api'; +import { Tables } from './types'; + +type RootTableState = { + tables: Tables; + table: Table; + rootTable: Tables[number] | undefined; +}; + +export const rootTableContext = createContext({ + table: {}, + tables: [], + rootTable: undefined, +}); + +/** + * RootTableProvider + * + * Provides tables array, table definition (just name) and root table (full table) in context so it can be accessed in any of its children + * Not to be confused with TableProvider, which provides the current table + * RootTableProvider is meant to be used just once, at the top of the tree, + * whereas there are many TableProvider in the tree, used mainly for selecting relationships + */ +export const RootTableProvider = ({ + children, + table, + tables, +}: Omit & { + children?: React.ReactNode | undefined; +}) => { + const rootTable = tables.find(t => areTablesEqual(t.table, table)); + return ( + + {children} + + ); +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RowPermissionsInput.stories.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RowPermissionsInput.stories.tsx index 3c6c598f3ae..4279fdd1e34 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RowPermissionsInput.stories.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RowPermissionsInput.stories.tsx @@ -38,6 +38,8 @@ export const SetRootLevelPermission: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{}} /> ), @@ -56,6 +58,8 @@ export const SetExistsPermission: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{}} /> ), @@ -95,6 +99,8 @@ export const SetMultilevelExistsPermission: StoryObj< table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{}} /> ), @@ -142,6 +148,8 @@ export const SetAndPermission: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{}} /> ), @@ -176,6 +184,8 @@ export const SetMultilevelAndPermission: StoryObj = table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{}} /> ), @@ -218,6 +228,8 @@ export const SetNotPermission: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{}} /> ), @@ -244,6 +256,8 @@ export const SetOrPermission: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{}} /> ), @@ -276,6 +290,8 @@ export const SetMultilevelOrPermission: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{}} /> ), @@ -317,6 +333,8 @@ export const Empty: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{}} /> ), @@ -329,6 +347,8 @@ export const Exists: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ _exists: { _table: {}, @@ -347,6 +367,8 @@ export const SetDisabledExistsPermission: StoryObj = table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ _exists: { _table: {}, @@ -378,6 +400,8 @@ export const ExistsWhere: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ _exists: { _table: { dataset: 'bigquery_sample', name: 'sample_table' }, @@ -400,6 +424,8 @@ export const EmptyExists: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ _exists: { _table: {}, @@ -417,6 +443,8 @@ export const And: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ _and: [ { STATUS: { _eq: 'X-Hasura-User-Id' } }, @@ -434,6 +462,8 @@ export const EmptyAnd: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ _and: [{}], }} @@ -448,6 +478,8 @@ export const Not: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ _not: { STATUS: { _eq: 'X-Hasura-User-Id' } }, }} @@ -462,6 +494,8 @@ export const EmptyNot: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ _not: {}, }} @@ -476,6 +510,8 @@ export const Relationships: StoryObj = { table={['Album']} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ Author: { name: { _eq: '' } } }} /> ), @@ -488,6 +524,8 @@ export const RelationshipsColumns: StoryObj = { table={['Album']} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ Label: { id: { _eq: '' } } }} /> ), @@ -500,6 +538,8 @@ export const ColumnTypes: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ Series_reference: { _eq: '' } }} /> ), @@ -512,6 +552,8 @@ export const BooleanArrayType: StoryObj = { table={['Album']} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ Author: { _ceq: ['name'] } }} /> ), @@ -536,6 +578,8 @@ export const BooleanArrayTypeRoot: StoryObj = { table={['Album']} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ Author: { _ceq: [ @@ -589,6 +633,8 @@ export const StringObjectType: StoryObj = { table={{ name: 'user_location', schema: 'public' }} tables={tableWithGeolocationSupport} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ location: { _st_d_within: { @@ -619,6 +665,8 @@ export const NumericValue: StoryObj = { table={['Album']} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ id: { _eq: '' } }} /> ), @@ -638,6 +686,8 @@ export const NumericIntValue: StoryObj = { table={['Album']} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ id: { _eq: 0 } }} /> ), @@ -650,6 +700,8 @@ export const NumericFloatValue: StoryObj = { table={['Album']} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ id: { _eq: 0.9 } }} /> ), @@ -670,6 +722,8 @@ export const JsonbColumns: StoryObj = { table={{ schema: 'public', name: 'Stuff' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ jason: { _contained_in: { a: 'b' } } }} /> ); @@ -710,6 +764,8 @@ export const JsonbColumnsHasKeys: StoryObj = { table={{ schema: 'public', name: 'Stuff' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ jason: { _has_keys_all: [''] } }} /> ); @@ -737,6 +793,8 @@ export const StringColumns: StoryObj = { table={{ schema: 'public', name: 'Stuff' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={permissions} /> ); @@ -752,13 +810,7 @@ export const StringColumns: StoryObj = { // Write a number in the input await userEvent.type(canvas.getByTestId('name._eq-value-input'), '1337'); - const onPermissionsChangeMock = args.onPermissionsChange as jest.Mock; - - const latestPermissions = - onPermissionsChangeMock?.mock.calls[ - onPermissionsChangeMock?.mock.calls.length - 1 - ][0]; - expect(latestPermissions).toEqual({ + expect(args.onPermissionsChange).toHaveBeenCalledWith({ name: { _eq: 1337, }, @@ -787,6 +839,8 @@ export const NumberColumns: StoryObj = { table={{ schema: 'public', name: 'Stuff' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={permissions} /> ); @@ -802,13 +856,7 @@ export const NumberColumns: StoryObj = { // // Write a number in the input await userEvent.type(canvas.getByTestId('id._eq-value-input'), '1337'); - const onPermissionsChangeMock = args.onPermissionsChange as jest.Mock; - - const latestPermissions = - onPermissionsChangeMock?.mock.calls[ - onPermissionsChangeMock?.mock.calls.length - 1 - ][0]; - expect(latestPermissions).toEqual({ + expect(args.onPermissionsChange).toHaveBeenCalledWith({ id: { _eq: 12341337, }, @@ -823,6 +871,8 @@ export const OperatorDropdownHandling: StoryObj = { table={{ dataset: 'bigquery_sample', name: 'sample_table' }} tables={tables} comparators={comparators} + logicalModel={undefined} + logicalModels={[]} permissions={{ _not: { STATUS: { _eq: 'X-Hasura-User-Id' } }, }} diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RowPermissionsInput.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RowPermissionsInput.tsx index 346335ac903..cca72635f00 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RowPermissionsInput.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RowPermissionsInput.tsx @@ -1,23 +1,36 @@ -import { Table } from '../../../../../hasura-metadata-types'; +import { LogicalModel, Table } from '../../../../../hasura-metadata-types'; import { Tables, Operators, Permissions, Comparators } from './types'; import { RowPermissionsProvider } from './RowPermissionsProvider'; import { TypesProvider } from './TypesProvider'; import { TableProvider } from './TableProvider'; import { RootInput } from './RootInput'; import { JsonEditor } from './JsonEditor'; +import { RootTableProvider } from './RootTableProvider'; +import { RootLogicalModelProvider } from './RootLogicalModelProvider'; +import { LogicalModelWithSourceName } from '../../../../../Data/LogicalModels/LogicalModelPermissions/components/types'; +import { + ForbiddenFeaturesProvider, + Feature, +} from './ForbiddenFeaturesProvider'; export const RowPermissionsInput = ({ permissions, tables, table, + logicalModel, + logicalModels, onPermissionsChange, comparators, + forbidden, }: { permissions: Permissions; tables: Tables; - table: Table; + table: Table | undefined; + logicalModels: LogicalModelWithSourceName[]; + logicalModel: LogicalModel['name'] | undefined; onPermissionsChange?: (permissions: Permissions) => void; comparators: Comparators; + forbidden?: Feature[]; }) => { const operators: Operators = { boolean: { @@ -34,22 +47,29 @@ export const RowPermissionsInput = ({ }, }; return ( - - - - - - - - - - + + + + + + + + + + + + + + + + ); }; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RowPermissionsProvider.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RowPermissionsProvider.tsx index 56c9bdec932..8d834168331 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RowPermissionsProvider.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/RowPermissionsProvider.tsx @@ -4,8 +4,6 @@ import { Permissions, RowPermissionsState } from './types'; import { updateKey } from './utils/helpers'; export const rowPermissionsContext = createContext({ - table: {}, - tables: [], comparators: {}, operators: {}, permissions: {}, @@ -18,14 +16,9 @@ export const RowPermissionsProvider = ({ children, operators, permissions, - table, - tables, comparators, onPermissionsChange, -}: Pick< - RowPermissionsState, - 'permissions' | 'operators' | 'table' | 'tables' | 'comparators' -> & { +}: Pick & { children?: React.ReactNode | undefined; onPermissionsChange?: (permissions: Permissions) => void; }) => { @@ -79,9 +72,7 @@ export const RowPermissionsProvider = ({ ...permissionsState, setValue, setKey, - table, setPermissions, - tables, comparators, }} > diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/SelectTable.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/SelectTable.tsx index 4bfd07ebacd..d16c6ca11af 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/SelectTable.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/SelectTable.tsx @@ -4,6 +4,7 @@ import { Table } from '../../../../../hasura-metadata-types'; import { getTableDisplayName } from '../../../../../DatabaseRelationships'; import { tableContext } from './TableProvider'; import { rowPermissionsContext } from './RowPermissionsProvider'; +import { rootTableContext } from './RootTableProvider'; export function SelectTable({ componentLevelId, @@ -16,7 +17,8 @@ export function SelectTable({ }) { const comparatorName = path[path.length - 1]; const { table, setTable, setComparator } = useContext(tableContext); - const { setValue, tables } = useContext(rowPermissionsContext); + const { setValue } = useContext(rowPermissionsContext); + const { tables } = useContext(rootTableContext); const stringifiedTable = JSON.stringify(table); // Sync table name with ColumnsContext table value useEffect(() => { diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/TableProvider.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/TableProvider.tsx index 562d87d8679..9950d661bbc 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/TableProvider.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/TableProvider.tsx @@ -1,8 +1,8 @@ import { useState, useContext, useEffect, createContext } from 'react'; -import { areTablesEqual } from '../../../../../hasura-metadata-api'; import { Table } from '../../../../../hasura-metadata-types'; -import { rowPermissionsContext } from './RowPermissionsProvider'; import { Columns, Relationships, TableContext } from './types'; +import { rootTableContext } from './RootTableProvider'; +import { areTablesEqual } from '../../../../../hasura-metadata-api'; export const tableContext = createContext({ table: {}, @@ -26,7 +26,7 @@ export const TableProvider = ({ const [comparator, setComparator] = useState(); const [columns, setColumns] = useState([]); const [relationships, setRelationships] = useState([]); - const { tables } = useContext(rowPermissionsContext); + const { tables } = useContext(rootTableContext); // Stringify values to get a stable value for useEffect const stringifiedTable = JSON.stringify(table); const stringifiedTables = JSON.stringify(tables); diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/TypesProvider.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/TypesProvider.tsx index b5ac77a23ea..f2003eda51f 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/TypesProvider.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/TypesProvider.tsx @@ -10,6 +10,7 @@ import { rowPermissionsContext } from './RowPermissionsProvider'; import set from 'lodash/set'; import unset from 'lodash/unset'; import { getPermissionTypes } from './utils/typeProviderHelpers'; +import { rootTableContext } from './RootTableProvider'; export const typesContext = createContext({ types: {}, @@ -22,7 +23,8 @@ export const TypesProvider = ({ children }: { children: React.ReactNode }) => { const [types, setTypes] = useState>( {} ); - const { permissions, tables, table } = useContext(rowPermissionsContext); + const { permissions } = useContext(rowPermissionsContext); + const { table, tables } = useContext(rootTableContext); const setType = useCallback( ({ type, diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/types.ts b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/types.ts index 02689e67cef..3ab69abd553 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/types.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/types.ts @@ -46,8 +46,6 @@ export type RowPermissionsState = { operators: Operators; permissions: Permissions; comparators: Comparators; - table: Table; - tables: Tables; setValue: (path: string[], value: any) => void; setKey: (props: { path: string[]; key: any; type: PermissionType }) => void; setPermissions: (permissions: Permissions) => void; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/utils/comparatorsFromSchema.ts b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/utils/comparatorsFromSchema.ts index a409d0d3446..505cdafaf74 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/utils/comparatorsFromSchema.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/components/RowPermissionsBuilder/components/utils/comparatorsFromSchema.ts @@ -8,6 +8,7 @@ import { Table } from '../../../../../../hasura-metadata-types'; import { useContext } from 'react'; import { rowPermissionsContext } from '../RowPermissionsProvider'; import { sourceDataTypes, SourceDataTypes } from './sourceDataTypes'; +import { rootTableContext } from '../RootTableProvider'; function columnOperators(): Array { return Object.keys(columnOperatorsInfo).reduce((acc, key) => { @@ -136,7 +137,8 @@ export const mapScalarDataType = ( }; export function useOperators({ path }: { path: string[] }) { - const { comparators, tables } = useContext(rowPermissionsContext); + const { comparators } = useContext(rowPermissionsContext); + const { tables } = useContext(rootTableContext); const { columns, table } = useContext(tableContext); const columnName = path[path.length - 2]; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/hooks/dataFetchingHooks/useFormData/createDefaultValues/index.ts b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/hooks/dataFetchingHooks/useFormData/createDefaultValues/index.ts index c154f64fa12..61bae82a876 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/hooks/dataFetchingHooks/useFormData/createDefaultValues/index.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/hooks/dataFetchingHooks/useFormData/createDefaultValues/index.ts @@ -69,7 +69,6 @@ export const createDefaultValues = ({ queryType: 'select', filterType: 'none', columns: {}, - supportedOperators, }; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/hooks/dataFetchingHooks/useFormData/useFormData.tsx b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/hooks/dataFetchingHooks/useFormData/useFormData.tsx index a88ca88ec57..fd0e6afe034 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/hooks/dataFetchingHooks/useFormData/useFormData.tsx +++ b/frontend/libs/console/legacy-ce/src/lib/features/Permissions/PermissionsForm/hooks/dataFetchingHooks/useFormData/useFormData.tsx @@ -67,17 +67,19 @@ export const useFormData = ({ dataSourceName, })) as Operator[]; - const defaultValues = createDefaultValues({ - queryType, - roleName, - dataSourceName, - metadata, - table, - tableColumns, - defaultQueryRoot, - metadataSource, - supportedOperators: supportedOperators ?? [], - }); + const defaultValues = { + ...createDefaultValues({ + queryType, + roleName, + dataSourceName, + metadata, + table, + tableColumns, + defaultQueryRoot, + metadataSource, + supportedOperators: supportedOperators ?? [], + }), + }; const formData = createFormData({ dataSourceName, diff --git a/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-api/selectors.ts b/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-api/selectors.ts index 794ccdabde4..edddf1984bf 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-api/selectors.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-api/selectors.ts @@ -1,3 +1,7 @@ +import { + LogicalModelWithSource, + NativeQueryWithSource, +} from '../Data/LogicalModels/types'; import { Metadata, Table } from '../hasura-metadata-types'; import * as utils from './utils'; @@ -31,3 +35,29 @@ export const findTable = utils.findMetadataTable(dataSourceName, table, m); export const resourceVersion = () => (m: Metadata) => m?.resource_version; + +export const extractModelsAndQueriesFromMetadata = ( + m: Metadata +): { queries: NativeQueryWithSource[]; models: LogicalModelWithSource[] } => { + const sources = m.metadata.sources; + let models: LogicalModelWithSource[] = []; + let queries: NativeQueryWithSource[] = []; + + sources.forEach(s => { + if (s.logical_models && s.logical_models.length > 0) { + models = [...models, ...s.logical_models.map(m => ({ ...m, source: s }))]; + } + + if (s.native_queries && s.native_queries.length > 0) { + queries = [ + ...queries, + ...s.native_queries.map(q => ({ ...q, source: s })), + ]; + } + }); + + return { + models, + queries, + }; +}; diff --git a/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-types/source/logicalModel.ts b/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-types/source/logicalModel.ts index dedee3c1b50..fd952a0de72 100644 --- a/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-types/source/logicalModel.ts +++ b/frontend/libs/console/legacy-ce/src/lib/features/hasura-metadata-types/source/logicalModel.ts @@ -7,4 +7,11 @@ export type LogicalModelField = { export type LogicalModel = { fields: LogicalModelField[]; name: string; + select_permissions?: { + permission: { + columns: string[]; + filter: Record; + }; + role: string; + }[]; };
+ Allow role {permission.roleName} to access{' '} + columns: +