Logical Model Permissions

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9276
Co-authored-by: Erik Magnusson <32518962+ejkkan@users.noreply.github.com>
GitOrigin-RevId: ee1dddf813fac3f3a29c5444644e589a8e67d3cb
This commit is contained in:
Julian 2023-06-01 15:29:55 -03:00 committed by hasura-bot
parent 6102270f11
commit 921b148dc8
55 changed files with 2333 additions and 113 deletions

View File

@ -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 { SERVER_CONSOLE_MODE } from '../../../constants';
import globals from '../../../Globals'; import globals from '../../../Globals';
@ -39,6 +39,7 @@ import { ModifyTableContainer } from './TableModify/ModifyTableContainer';
import { LandingPageRoute as NativeQueries } from '../../../features/Data/LogicalModels/LandingPage/LandingPage'; import { LandingPageRoute as NativeQueries } from '../../../features/Data/LogicalModels/LandingPage/LandingPage';
import { TrackStoredProcedureRoute } from '../../../features/Data/LogicalModels/StoredProcedures/StoredProcedureWidget.route'; 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 { ManageFunction } from '../../../features/Data/ManageFunction/ManageFunction';
import { import {
UpdateNativeQueryRoute, UpdateNativeQueryRoute,
@ -90,7 +91,16 @@ const makeDataRouter = (
path="native-query/:source/:name" path="native-query/:source/:name"
component={UpdateNativeQueryRoute} component={UpdateNativeQueryRoute}
/> />
<Route path="logical-models" component={NativeQueries} /> <Route path="logical-models">
<IndexRoute component={NativeQueries} />
<Redirect from=":source" to="/data/native-queries/logical-models" />
<Route path=":source">
<Route
path=":name/permissions"
component={LogicalModelPermissionsRoute}
/>
</Route>
</Route>
<Route path="stored-procedures" component={NativeQueries} /> <Route path="stored-procedures" component={NativeQueries} />
<Route <Route
path="stored-procedures/track" path="stored-procedures/track"

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import React, { useState } from 'react';
import { InjectedRouter, Link, withRouter } from 'react-router'; import { InjectedRouter, Link, withRouter } from 'react-router';
import { useDestructiveAlert } from '../../../../new-components/Alert'; import { useDestructiveAlert } from '../../../../new-components/Alert';
import { Button } from '../../../../new-components/Button'; import { Button } from '../../../../new-components/Button';
@ -9,14 +9,14 @@ import { useMetadata } from '../../../hasura-metadata-api';
import { useTrackLogicalModel } from '../../hooks/useTrackLogicalModel'; import { useTrackLogicalModel } from '../../hooks/useTrackLogicalModel';
import { useTrackNativeQuery } from '../../hooks/useTrackNativeQuery'; import { useTrackNativeQuery } from '../../hooks/useTrackNativeQuery';
import { LogicalModelWidget } from '../LogicalModelWidget/LogicalModelWidget'; import { LogicalModelWidget } from '../LogicalModelWidget/LogicalModelWidget';
import { RouteWrapper } from '../components/RouteWrapper';
import { LogicalModelWithSource, NativeQueryWithSource } from '../types'; import { LogicalModelWithSource, NativeQueryWithSource } from '../types';
import { extractModelsAndQueriesFromMetadata } from '../utils';
import { ListLogicalModels } from './components/ListLogicalModels'; import { ListLogicalModels } from './components/ListLogicalModels';
import { ListNativeQueries } from './components/ListNativeQueries'; import { ListNativeQueries } from './components/ListNativeQueries';
import { ListStoredProcedures } from './components/ListStoredProcedures'; import { ListStoredProcedures } from './components/ListStoredProcedures';
import { NATIVE_QUERY_ROUTES } from '../constants'; import { NATIVE_QUERY_ROUTES } from '../constants';
import { extractModelsAndQueriesFromMetadata } from '../../../hasura-metadata-api/selectors';
import { RouteWrapper } from '../components/RouteWrapper';
export const LandingPage = ({ pathname }: { pathname: string }) => { export const LandingPage = ({ pathname }: { pathname: string }) => {
const push = usePushRoute(); const push = usePushRoute();
@ -149,8 +149,10 @@ export const LandingPage = ({ pathname }: { pathname: string }) => {
<ListLogicalModels <ListLogicalModels
logicalModels={logicalModels} logicalModels={logicalModels}
isLoading={isLoading} isLoading={isLoading}
onEditClick={() => { onEditClick={model => {
push?.('/data/native-queries/logical-models/permissions'); push?.(
`/data/native-queries/logical-models/${model.source.name}/${model.name}/permissions`
);
}} }}
onRemoveClick={handleRemoveLogicalModel} onRemoveClick={handleRemoveLogicalModel}
/> />

View File

@ -1,7 +1,7 @@
import { StoryObj, Meta } from '@storybook/react'; import { StoryObj, Meta } from '@storybook/react';
import { buildMetadata } from '../../mocks/metadata'; import { buildMetadata } from '../../mocks/metadata';
import { extractModelsAndQueriesFromMetadata } from '../../utils';
import { ListLogicalModels } from './ListLogicalModels'; import { ListLogicalModels } from './ListLogicalModels';
import { extractModelsAndQueriesFromMetadata } from '../../../../hasura-metadata-api/selectors';
export default { export default {
component: ListLogicalModels, component: ListLogicalModels,

View File

@ -39,8 +39,9 @@ export const ListLogicalModels = ({
header: 'Actions', header: 'Actions',
cell: ({ cell, row }) => ( cell: ({ cell, row }) => (
<div className="flex flex-row gap-2"> <div className="flex flex-row gap-2">
{/* Re add once we implement Edit functionality */} <Button onClick={() => onEditClick(row.original)}>
{/* <Button onClick={() => onEditClick(row.original)}>Edit</Button> */} Edit Permissions
</Button>
<Button <Button
mode="destructive" mode="destructive"
onClick={() => onRemoveClick(row.original)} onClick={() => onRemoveClick(row.original)}

View File

@ -2,7 +2,7 @@ import { StoryObj, Meta } from '@storybook/react';
import { ListNativeQueries } from './ListNativeQueries'; import { ListNativeQueries } from './ListNativeQueries';
import { buildMetadata } from '../../mocks/metadata'; import { buildMetadata } from '../../mocks/metadata';
import { extractModelsAndQueriesFromMetadata } from '../../utils'; import { extractModelsAndQueriesFromMetadata } from '../../../../hasura-metadata-api/selectors';
export default { export default {
component: ListNativeQueries, component: ListNativeQueries,

View File

@ -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<typeof LogicalModelPermissions>;
const noPermissionArgs: Partial<
ComponentProps<typeof LogicalModelPermissions>
> = {
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<typeof LogicalModelPermissions>
> = {
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',
});
},
};

View File

@ -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 (
<LogicalModelPermissionsFormProvider logicalModel={logicalModel}>
<PureLogicalModelPermissions
onSave={onSave}
onDelete={onDelete}
isCreating={isCreating}
isRemoving={isRemoving}
comparators={comparators}
logicalModelName={logicalModelName}
logicalModels={logicalModels}
/>
</LogicalModelPermissionsFormProvider>
);
};
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 (
<div className="p-4">
<div className="grid gap-4">
<PermissionsTable
allowedActions={allowedActions}
permissions={permissions}
/>
{permission && (
<PermissionsForm
permission={permission}
onSave={onSave}
onDelete={onDelete}
isCreating={isCreating}
isRemoving={isRemoving}
PermissionsInput={
<RowPermissionsInput
table={undefined}
forbidden={['exists']}
tables={[]}
onPermissionsChange={permissionFilter => {
setPermission(permission.roleName, permissionFilter);
}}
logicalModel={logicalModelName}
logicalModels={logicalModels}
permissions={permission.filter}
comparators={comparators}
/>
}
/>
)}
</div>
</div>
);
};

View File

@ -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 (
<RouteWrapper
route={
'/data/native-queries/logical-models/{{source}}/{{name}}/permissions'
}
itemSourceName={source}
itemName={name}
>
<LogicalModelPermissionsPage {...params} />
</RouteWrapper>
);
},
decorators: [ReactQueryDecorator()],
} as Meta;
type Story = StoryObj<typeof LogicalModelPermissionsPage>;
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',
},
};

View File

@ -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 (
<Tabs
// Recreate the key when the logical model permissions change to reset the form
key={logicalModel?.select_permissions?.length}
data-testid="logical-model-permissions-tab"
defaultValue={'logical-model-permissions'}
items={[
{
value: 'logical-model-permissions',
label: `Permissions`,
content: (
<div className="mt-md">
{isLoading ? (
<div
className="flex items-center justify-center h-64"
data-testid="loading-logical-model-permissions"
>
<Skeleton />
</div>
) : !logicalModel ? (
<div className="flex items-center justify-center h-64">
<span className="text-gray-500">
Logical model with name {name} and driver {source} not found
</span>
</div>
) : (
<LogicalModelPermissions
onSave={async permission => {
create({
logicalModelName: logicalModel?.name,
permission,
});
}}
onDelete={async permission => {
remove({
logicalModelName: logicalModel?.name,
permission,
});
}}
isCreating={isCreating}
isRemoving={isRemoving}
comparators={comparators}
logicalModelName={logicalModel?.name}
logicalModels={logicalModels}
/>
)}
</div>
),
},
]}
/>
);
};
export const LogicalModelPermissionsRoute = withRouter<{
location: Location;
router: InjectedRouter;
params: {
source: string;
name: string;
};
}>(({ params }) => {
return (
<RouteWrapper
route={
'/data/native-queries/logical-models/{{source}}/{{name}}/permissions'
}
itemSourceName={params.source}
itemName={params.name}
>
<LogicalModelPermissionsPage source={params.source} name={params.name} />
</RouteWrapper>
);
});

View File

@ -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 <FormProvider {...methods}>{children}</FormProvider>;
}

View File

@ -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<PermissionAccessCellProps> = ({
access,
isEditable,
isCurrentEdit,
...rest
}) => {
if (!isEditable) {
return (
<td className="p-md whitespace-nowrap text-center cursor-not-allowed opacity-30">
<PermissionsIcon type={access} selected={isCurrentEdit} />
</td>
);
}
return (
<td>
<button
type="submit"
className={`cursor-pointer h-20 border-none w-full whitespace-nowrap text-center ${
isCurrentEdit ? 'bg-amber-300' : 'hover:bg-indigo-50'
}`}
{...rest}
>
<PermissionsIcon type={access} selected={isCurrentEdit} />
</button>
</td>
);
};

View File

@ -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 (
<form
onSubmit={e => {
e.preventDefault();
onSave();
}}
>
<div
className="bg-white rounded p-md border border-gray-300"
data-testid="permissions-form"
>
<div className="pb-4 flex items-center gap-4">
<Button
type="button"
onClick={() => {
unsetActivePermission();
}}
>
Close
</Button>
<h3 data-testid="form-title">
<strong>Role:</strong>
<Badge className="mx-2" data-testid="role-pill">
{permission.roleName}
</Badge>
<strong>Action:</strong>
<Badge className="mx-2" data-testid="action-pill">
{permission.action}
</Badge>
</h3>
</div>
<fieldset className="grid gap-2">
<div>
<label className="flex items-center gap-2">
<input
id={'without_filter'}
type="radio"
value={'without_filter'}
checked={rowSelectPermissions === 'without_filter'}
data-testid="without-filter"
onClick={() => {
setRowSelectPermissions('without_filter');
setPermission(permission.roleName, {});
}}
/>
<NoChecksLabel />
</label>
</div>
<div>
<label className="flex items-center gap-2">
<input
id={'with_custom_filter'}
type="radio"
value={rowSelectPermissions}
checked={rowSelectPermissions === 'with_custom_filter'}
onClick={() => {
setRowSelectPermissions('with_custom_filter');
}}
/>
<CustomLabel />
</label>
{rowSelectPermissions === 'with_custom_filter' && (
<div className="pt-4">
<div>{PermissionsInput}</div>
</div>
)}
</div>
</fieldset>
<Collapse defaultOpen>
<Collapse.Header
title={`Column ${permission.action} permissions`}
tooltip={`Choose columns allowed to be ${getEdForm(
permission.action
)}`}
status={columnPermissionsStatus(permission)}
disabledMessage="Set row permissions first"
/>
<Collapse.Content>
<div className="grid gap-2">
<div className="flex gap-2 items-center">
<p>
Allow role <strong>{permission.roleName}</strong> to access{' '}
<strong>columns</strong>:
</p>
</div>
<fieldset className="flex gap-4 flex-wrap">
{columns?.map(column => (
<label key={column} className="flex gap-2 items-center">
<input
type="checkbox"
data-testid={`column-${column}-checkbox`}
style={{ marginTop: '0px !important' }}
className="rounded shadow-sm border border-gray-300 hover:border-gray-400 focus:ring-yellow-400"
checked={permission.columns.includes(column)}
onChange={() => {
toggleColumn(permission, column);
}}
/>
<i>{column}</i>
</label>
))}
<Button
type="button"
size="sm"
onClick={() => toggleAllColumns(permission)}
data-testid="toggle-all-columns"
>
Toggle All
</Button>
</fieldset>
</div>
</Collapse.Content>
</Collapse>
<div className="pt-2 flex gap-2 mt-4" id="form-buttons-container">
<Button
isLoading={isCreating}
disabled={isRemoving || isCreating}
type="submit"
mode="primary"
title={'Submit'}
data-testid="save-permissions-button"
>
Save Permissions
</Button>
<Button
isLoading={isRemoving}
disabled={isRemoving || isCreating}
type="button"
mode="destructive"
onClick={onDelete}
data-testid="delete-permissions-button"
>
Delete Permissions
</Button>
</div>
</div>
</form>
);
};
const NoChecksLabel = () => (
<span data-test="without-checks">Without any checks&nbsp;</span>
);
const CustomLabel = () => (
<span data-test="custom-check" className="flex items-center">
With custom check:
<IconTooltip message="Create custom check using permissions builder" />
</span>
);

View File

@ -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<HTMLInputElement, PermissionsRowProps>(
({ permission, actions, allowedActions, index }, ref) => {
const {
setActivePermission,
activePermission,
unsetActivePermission,
permissionAccess,
} = usePermissionsFormContext();
return (
<tr className="group divide-x divide-gray-300">
<PermissionsRowName
ref={ref}
roleName={permission.roleName}
isNew={permission.isNew}
/>
{actions.map(actionName => {
const action = allowedActions.find(
allowedAction => allowedAction === actionName
);
return (
<PermissionAccessCell
key={actionName}
isEditable={Boolean(action)}
access={permissionAccess(action, permission)}
isCurrentEdit={
activePermission === index && permission.action === action
}
data-testid={`${permission.roleName}-${actionName}-permissions-cell`}
onClick={() => {
// 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);
}
}}
/>
);
})}
</tr>
);
}
);

View File

@ -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 (
<td className="w-0 bg-gray-50 p-sm font-semibold text-muted">
<input
ref={inputRef}
className="block w-64 h-input px-md shadow-sm rounded border border-gray-300 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-yellow-200 focus:border-yellow-400"
value={roleName}
aria-label="create-new-role"
data-testid="new-role-input"
placeholder="Create new role..."
onChange={e => {
setNewRoleName(e.target.value);
}}
/>
</td>
);
}
return (
<td className="w-0 bg-gray-50 p-md font-semibold text-muted">
<div className="flex items-center">
<label className="flex items-center ml-sm">{roleName}</label>
</div>
</td>
);
});

View File

@ -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<HTMLInputElement>();
return (
<div className="grid gap-2" data-testid="permissions-table">
<div className="flex gap-4">
<span>
<PermissionsIcon type="fullAccess" />
&nbsp;-&nbsp;full access
</span>
<span>
<PermissionsIcon type="noAccess" />
&nbsp;-&nbsp;no access
</span>
<span>
<PermissionsIcon type="partialAccess" />
&nbsp;-&nbsp;partial access
</span>
</div>
<div className="overflow-x-auto border border-gray-300 rounded">
<table className="min-w-full divide-y divide-gray-200 text-left">
<thead>
<tr className="divide-x divide-gray-300">
<th className="w-0 bg-gray-50 border-r border-gray-200 px-md py-sm text-sm font-semibold text-muted uppercase tracking-wider">
ROLE
</th>
{actions.map(action => (
<th
className="bg-gray-50 px-md py-sm text-sm font-semibold text-muted text-center uppercase tracking-wider"
key={action}
>
{action.toUpperCase()}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-300">
{permissions.map((permission, index) => (
// Using index as key instead of role.name because role.name is editable
<PermissionsRow
key={index}
index={index}
ref={inputRef}
permission={permission}
actions={actions}
allowedActions={allowedActions}
/>
))}
</tbody>
</table>
</div>
</div>
);
};

View File

@ -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<string, any>;
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<string, any>;
};
}[];
};
export type Role = {
name: string;
isNew?: boolean;
};
export type AccessType = 'fullAccess' | 'noAccess' | 'partialAccess';
export type OnSave = (permission: Permission) => Promise<void>;
export type OnDelete = (permission: Permission) => Promise<void>;
export type RowSelectPermissionsType = 'with_custom_filter' | 'without_filter';

View File

@ -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 };

View File

@ -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<LogicalModelPermissionsState>({
defaultValues: {
activePermission: null,
rowSelectPermissions: 'without_filter',
permissions: defaultPermissions,
columns: logicalModel?.fields?.map(field => field.name) ?? [],
},
});
return methods;
}
export function usePermissionsFormContext() {
const { setValue, watch } = useFormContext<LogicalModelPermissionsState>();
return {
permissions: watch('permissions'),
setPermission: (roleName: string, filter: Record<string, any>) => {
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';
},
};
}

View File

@ -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 };

View File

@ -0,0 +1,16 @@
export function errorTransform(error: any) {
const err = error as Record<string, any>;
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,
};
}

View File

@ -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<string, unknown>;
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;
};

View File

@ -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;
};

View File

@ -0,0 +1,36 @@
type GetPermissionValuesInputType = {
roleName: string;
filter?: Record<string, unknown>;
check?: Record<string, unknown>;
columns?: string[];
action: string;
isNew: boolean;
source: string;
};
type GetPermissionValuesOutputType = {
filter?: Record<string, unknown>;
check?: Record<string, unknown>;
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;
};

View File

@ -0,0 +1,3 @@
export function mapPostgresToPg(kind: string) {
return kind === 'postgres' ? 'pg' : kind;
}

View File

@ -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,
},
],
};

View File

@ -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,
};

View File

@ -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',
})
);
}),
];
};

View File

@ -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,
},
},
},
],
};

View File

@ -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 };

View File

@ -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';
}
}

View File

@ -1,5 +1,5 @@
import startCase from 'lodash/startCase'; import startCase from 'lodash/startCase';
import { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import Skeleton from 'react-loading-skeleton'; import Skeleton from 'react-loading-skeleton';
import { useServerConfig } from '../../../../hooks'; import { useServerConfig } from '../../../../hooks';
import { Breadcrumbs } from '../../../../new-components/Breadcrumbs'; import { Breadcrumbs } from '../../../../new-components/Breadcrumbs';

View File

@ -49,4 +49,9 @@ export const NATIVE_QUERY_ROUTES = {
title: 'Track Stored Procedure', title: 'Track Stored Procedure',
subtitle: 'Expose your stored SQL procedures via the GraphQL API', 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',
},
}; };

View File

@ -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,
};
};

View File

@ -41,6 +41,8 @@ export const RowPermissionBuilder = ({
}} }}
table={table} table={table}
tables={tables} tables={tables}
logicalModel={undefined}
logicalModels={[]}
permissions={value} permissions={value}
comparators={comparators} comparators={comparators}
/> />

View File

@ -1,7 +1,9 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { rowPermissionsContext } from './RowPermissionsProvider'; import { rowPermissionsContext } from './RowPermissionsProvider';
import { useOperators } from './utils/comparatorsFromSchema'; 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 = ({ export const Comparator = ({
comparator, comparator,
@ -21,7 +23,20 @@ export const Comparator = ({
inputId={`${comparatorLevelId}-select-value`} inputId={`${comparatorLevelId}-select-value`}
isSearchable isSearchable
aria-label={comparatorLevelId} aria-label={comparatorLevelId}
components={{ DropdownIndicator: null }} components={{
DropdownIndicator: props => {
const { className } = props;
return (
<components.DropdownIndicator
{...props}
className={clsx(className, '!text-gray-500 hover:!text-gray-500')}
>
<FiChevronDown className="w-5 h-5" />
</components.DropdownIndicator>
);
},
IndicatorSeparator: () => null,
}}
options={operators.map(o => ({ options={operators.map(o => ({
value: o.name, value: o.name,
label: o.name, label: o.name,

View File

@ -5,6 +5,7 @@ import { isColumnComparator } from './utils';
import { ColumnComparatorEntry } from './EntryTypes/ColumnComparatorEntry'; import { ColumnComparatorEntry } from './EntryTypes/ColumnComparatorEntry';
import { useOperators } from './utils/comparatorsFromSchema'; import { useOperators } from './utils/comparatorsFromSchema';
import { ValueInput } from './ValueInput'; import { ValueInput } from './ValueInput';
import { useForbiddenFeatures } from './ForbiddenFeaturesProvider';
export const EntryType = ({ export const EntryType = ({
k, k,
@ -17,6 +18,7 @@ export const EntryType = ({
}) => { }) => {
const operators = useOperators({ path }); const operators = useOperators({ path });
const operator = operators.find(o => o.name === k); const operator = operators.find(o => o.name === k);
const { hasFeature } = useForbiddenFeatures();
if (isColumnComparator(k)) { if (isColumnComparator(k)) {
return <ColumnComparatorEntry k={k} path={path} v={v} />; return <ColumnComparatorEntry k={k} path={path} v={v} />;
} }
@ -27,6 +29,9 @@ export const EntryType = ({
return <ArrayEntry k={k} v={v} path={path} />; return <ArrayEntry k={k} v={v} path={path} />;
} }
if (k === '_exists') { if (k === '_exists') {
if (!hasFeature('exists')) {
return null;
}
return <ExistsEntry k={k} v={v} path={path} />; return <ExistsEntry k={k} v={v} path={path} />;
} }
if ( if (

View File

@ -3,8 +3,8 @@ import { isComparator } from '../utils/helpers';
import { tableContext } from '../TableProvider'; import { tableContext } from '../TableProvider';
import { typesContext } from '../TypesProvider'; import { typesContext } from '../TypesProvider';
import { rowPermissionsContext } from '../RowPermissionsProvider'; import { rowPermissionsContext } from '../RowPermissionsProvider';
import { areTablesEqual } from '../../../../../../hasura-metadata-api';
import { createWrapper } from './utils'; import { createWrapper } from './utils';
import { rootTableContext } from '../RootTableProvider';
export function ColumnComparatorEntry({ export function ColumnComparatorEntry({
k, k,
@ -107,9 +107,9 @@ function RootColumnsSelect({
v: any; v: any;
path: string[]; path: string[];
}) { }) {
const { table, tables, setValue } = useContext(rowPermissionsContext); const { setValue } = useContext(rowPermissionsContext);
const value = v.find((v: any) => v !== '$'); 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`; const testId = `${path.join('.')}-root-column-comparator-entry`;
return ( return (
<select <select

View File

@ -0,0 +1,39 @@
import { ReactNode, createContext, useCallback, useContext } from 'react';
export type Feature = 'exists';
const forbiddenFeaturesContext = createContext<{
forbidden: Feature[];
hasFeature: (feature: Feature) => 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 (
<forbiddenFeaturesContext.Provider
value={{ forbidden: forbidden, hasFeature }}
>
{children}
</forbiddenFeaturesContext.Provider>
);
}

View File

@ -3,11 +3,11 @@ import { useContext } from 'react';
import { getTableDisplayName } from '../../../../../DatabaseRelationships'; import { getTableDisplayName } from '../../../../../DatabaseRelationships';
import { rowPermissionsContext } from './RowPermissionsProvider'; import { rowPermissionsContext } from './RowPermissionsProvider';
import { rootTableContext } from './RootTableProvider';
export const JsonEditor = () => { export const JsonEditor = () => {
const { permissions, table, setPermissions } = useContext( const { permissions, setPermissions } = useContext(rowPermissionsContext);
rowPermissionsContext const { table } = useContext(rootTableContext);
);
return ( return (
<div className="p-6 rounded-lg bg-white border border-gray-200 min-h-32 w-full"> <div className="p-6 rounded-lg bg-white border border-gray-200 min-h-32 w-full">
<AceEditor <AceEditor

View File

@ -0,0 +1,57 @@
import { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { RowPermissionsInput } from './RowPermissionsInput';
import { comparators } from './__tests__/fixtures/comparators';
import { handlers } from './__tests__/fixtures/jsonb/handlers';
import { ReactQueryDecorator } from '../../../../../../storybook/decorators/react-query';
export default {
title: 'Features/Permissions/Form/Logical Model Permissions Input',
component: RowPermissionsInput,
parameters: {
msw: handlers(),
},
decorators: [ReactQueryDecorator()],
} as Meta;
type Story = StoryObj<typeof RowPermissionsInput>;
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' } },
},
};

View File

@ -3,6 +3,8 @@ import { useContext } from 'react';
import { rowPermissionsContext } from './RowPermissionsProvider'; import { rowPermissionsContext } from './RowPermissionsProvider';
import { tableContext } from './TableProvider'; import { tableContext } from './TableProvider';
import { PermissionType } from './types'; import { PermissionType } from './types';
import { logicalModelContext } from './RootLogicalModelProvider';
import { useForbiddenFeatures } from './ForbiddenFeaturesProvider';
export const Operator = ({ export const Operator = ({
operator, operator,
@ -15,12 +17,14 @@ export const Operator = ({
}) => { }) => {
const { operators, setKey } = useContext(rowPermissionsContext); const { operators, setKey } = useContext(rowPermissionsContext);
const { columns, table, relationships } = useContext(tableContext); const { columns, table, relationships } = useContext(tableContext);
const { rootLogicalModel } = useContext(logicalModelContext);
const parent = path[path.length - 1]; const parent = path[path.length - 1];
const operatorLevelId = `${path?.join('.')}-operator`; const operatorLevelId = `${path?.join('.')}-operator`;
const { hasFeature } = useForbiddenFeatures();
return ( return (
<select <select
data-testid={operatorLevelId || 'root-operator-picker'} data-testid={operatorLevelId || 'root-operator-picker'}
className="border border-gray-200 rounded-md p-2" className="border border-gray-200 rounded-md p-2 pr-4"
value={operator} value={operator}
disabled={parent === '_where' && isEmpty(table)} disabled={parent === '_where' && isEmpty(table)}
onChange={e => { onChange={e => {
@ -55,7 +59,20 @@ export const Operator = ({
))} ))}
</optgroup> </optgroup>
) : null} ) : null}
{operators.exist?.items.length ? ( {rootLogicalModel?.fields.length ? (
<optgroup label="Columns">
{rootLogicalModel?.fields.map((field, index) => (
<option
data-type="column"
key={'column' + index}
value={field.name}
>
{field.name}
</option>
))}
</optgroup>
) : null}
{hasFeature('exists') && operators.exist?.items.length ? (
<optgroup label="Exist operators"> <optgroup label="Exist operators">
{operators.exist.items.map((item, index) => ( {operators.exist.items.map((item, index) => (
<option data-type="exist" key={'exist' + index} value={item.value}> <option data-type="exist" key={'exist' + index} value={item.value}>

View File

@ -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<LogicalModelState>({
logicalModel: '',
logicalModels: [],
rootLogicalModel: undefined,
});
export const RootLogicalModelProvider = ({
children,
logicalModel,
logicalModels,
}: Omit<LogicalModelState, 'rootLogicalModel'> & {
children?: React.ReactNode | undefined;
}) => {
const rootLogicalModel = logicalModels.find(t => t.name === logicalModel);
return (
<logicalModelContext.Provider
value={{
logicalModel,
logicalModels,
rootLogicalModel,
}}
>
{children}
</logicalModelContext.Provider>
);
};

View File

@ -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<RootTableState>({
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<RootTableState, 'rootTable'> & {
children?: React.ReactNode | undefined;
}) => {
const rootTable = tables.find(t => areTablesEqual(t.table, table));
return (
<rootTableContext.Provider
value={{
table,
tables,
rootTable,
}}
>
{children}
</rootTableContext.Provider>
);
};

View File

@ -38,6 +38,8 @@ export const SetRootLevelPermission: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{}} permissions={{}}
/> />
), ),
@ -56,6 +58,8 @@ export const SetExistsPermission: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{}} permissions={{}}
/> />
), ),
@ -95,6 +99,8 @@ export const SetMultilevelExistsPermission: StoryObj<
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{}} permissions={{}}
/> />
), ),
@ -142,6 +148,8 @@ export const SetAndPermission: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{}} permissions={{}}
/> />
), ),
@ -176,6 +184,8 @@ export const SetMultilevelAndPermission: StoryObj<typeof RowPermissionsInput> =
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{}} permissions={{}}
/> />
), ),
@ -218,6 +228,8 @@ export const SetNotPermission: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{}} permissions={{}}
/> />
), ),
@ -244,6 +256,8 @@ export const SetOrPermission: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{}} permissions={{}}
/> />
), ),
@ -276,6 +290,8 @@ export const SetMultilevelOrPermission: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{}} permissions={{}}
/> />
), ),
@ -317,6 +333,8 @@ export const Empty: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{}} permissions={{}}
/> />
), ),
@ -329,6 +347,8 @@ export const Exists: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ permissions={{
_exists: { _exists: {
_table: {}, _table: {},
@ -347,6 +367,8 @@ export const SetDisabledExistsPermission: StoryObj<typeof RowPermissionsInput> =
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ permissions={{
_exists: { _exists: {
_table: {}, _table: {},
@ -378,6 +400,8 @@ export const ExistsWhere: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ permissions={{
_exists: { _exists: {
_table: { dataset: 'bigquery_sample', name: 'sample_table' }, _table: { dataset: 'bigquery_sample', name: 'sample_table' },
@ -400,6 +424,8 @@ export const EmptyExists: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ permissions={{
_exists: { _exists: {
_table: {}, _table: {},
@ -417,6 +443,8 @@ export const And: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ permissions={{
_and: [ _and: [
{ STATUS: { _eq: 'X-Hasura-User-Id' } }, { STATUS: { _eq: 'X-Hasura-User-Id' } },
@ -434,6 +462,8 @@ export const EmptyAnd: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ permissions={{
_and: [{}], _and: [{}],
}} }}
@ -448,6 +478,8 @@ export const Not: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ permissions={{
_not: { STATUS: { _eq: 'X-Hasura-User-Id' } }, _not: { STATUS: { _eq: 'X-Hasura-User-Id' } },
}} }}
@ -462,6 +494,8 @@ export const EmptyNot: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ permissions={{
_not: {}, _not: {},
}} }}
@ -476,6 +510,8 @@ export const Relationships: StoryObj<typeof RowPermissionsInput> = {
table={['Album']} table={['Album']}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ Author: { name: { _eq: '' } } }} permissions={{ Author: { name: { _eq: '' } } }}
/> />
), ),
@ -488,6 +524,8 @@ export const RelationshipsColumns: StoryObj<typeof RowPermissionsInput> = {
table={['Album']} table={['Album']}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ Label: { id: { _eq: '' } } }} permissions={{ Label: { id: { _eq: '' } } }}
/> />
), ),
@ -500,6 +538,8 @@ export const ColumnTypes: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ Series_reference: { _eq: '' } }} permissions={{ Series_reference: { _eq: '' } }}
/> />
), ),
@ -512,6 +552,8 @@ export const BooleanArrayType: StoryObj<typeof RowPermissionsInput> = {
table={['Album']} table={['Album']}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ Author: { _ceq: ['name'] } }} permissions={{ Author: { _ceq: ['name'] } }}
/> />
), ),
@ -536,6 +578,8 @@ export const BooleanArrayTypeRoot: StoryObj<typeof RowPermissionsInput> = {
table={['Album']} table={['Album']}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ permissions={{
Author: { Author: {
_ceq: [ _ceq: [
@ -589,6 +633,8 @@ export const StringObjectType: StoryObj<typeof RowPermissionsInput> = {
table={{ name: 'user_location', schema: 'public' }} table={{ name: 'user_location', schema: 'public' }}
tables={tableWithGeolocationSupport} tables={tableWithGeolocationSupport}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ permissions={{
location: { location: {
_st_d_within: { _st_d_within: {
@ -619,6 +665,8 @@ export const NumericValue: StoryObj<typeof RowPermissionsInput> = {
table={['Album']} table={['Album']}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ id: { _eq: '' } }} permissions={{ id: { _eq: '' } }}
/> />
), ),
@ -638,6 +686,8 @@ export const NumericIntValue: StoryObj<typeof RowPermissionsInput> = {
table={['Album']} table={['Album']}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ id: { _eq: 0 } }} permissions={{ id: { _eq: 0 } }}
/> />
), ),
@ -650,6 +700,8 @@ export const NumericFloatValue: StoryObj<typeof RowPermissionsInput> = {
table={['Album']} table={['Album']}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ id: { _eq: 0.9 } }} permissions={{ id: { _eq: 0.9 } }}
/> />
), ),
@ -670,6 +722,8 @@ export const JsonbColumns: StoryObj<typeof RowPermissionsInput> = {
table={{ schema: 'public', name: 'Stuff' }} table={{ schema: 'public', name: 'Stuff' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ jason: { _contained_in: { a: 'b' } } }} permissions={{ jason: { _contained_in: { a: 'b' } } }}
/> />
); );
@ -710,6 +764,8 @@ export const JsonbColumnsHasKeys: StoryObj<typeof RowPermissionsInput> = {
table={{ schema: 'public', name: 'Stuff' }} table={{ schema: 'public', name: 'Stuff' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ jason: { _has_keys_all: [''] } }} permissions={{ jason: { _has_keys_all: [''] } }}
/> />
); );
@ -737,6 +793,8 @@ export const StringColumns: StoryObj<typeof RowPermissionsInput> = {
table={{ schema: 'public', name: 'Stuff' }} table={{ schema: 'public', name: 'Stuff' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={permissions} permissions={permissions}
/> />
); );
@ -752,13 +810,7 @@ export const StringColumns: StoryObj<typeof RowPermissionsInput> = {
// Write a number in the input // Write a number in the input
await userEvent.type(canvas.getByTestId('name._eq-value-input'), '1337'); await userEvent.type(canvas.getByTestId('name._eq-value-input'), '1337');
const onPermissionsChangeMock = args.onPermissionsChange as jest.Mock; expect(args.onPermissionsChange).toHaveBeenCalledWith({
const latestPermissions =
onPermissionsChangeMock?.mock.calls[
onPermissionsChangeMock?.mock.calls.length - 1
][0];
expect(latestPermissions).toEqual({
name: { name: {
_eq: 1337, _eq: 1337,
}, },
@ -787,6 +839,8 @@ export const NumberColumns: StoryObj<typeof RowPermissionsInput> = {
table={{ schema: 'public', name: 'Stuff' }} table={{ schema: 'public', name: 'Stuff' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={permissions} permissions={permissions}
/> />
); );
@ -802,13 +856,7 @@ export const NumberColumns: StoryObj<typeof RowPermissionsInput> = {
// // Write a number in the input // // Write a number in the input
await userEvent.type(canvas.getByTestId('id._eq-value-input'), '1337'); await userEvent.type(canvas.getByTestId('id._eq-value-input'), '1337');
const onPermissionsChangeMock = args.onPermissionsChange as jest.Mock; expect(args.onPermissionsChange).toHaveBeenCalledWith({
const latestPermissions =
onPermissionsChangeMock?.mock.calls[
onPermissionsChangeMock?.mock.calls.length - 1
][0];
expect(latestPermissions).toEqual({
id: { id: {
_eq: 12341337, _eq: 12341337,
}, },
@ -823,6 +871,8 @@ export const OperatorDropdownHandling: StoryObj<typeof RowPermissionsInput> = {
table={{ dataset: 'bigquery_sample', name: 'sample_table' }} table={{ dataset: 'bigquery_sample', name: 'sample_table' }}
tables={tables} tables={tables}
comparators={comparators} comparators={comparators}
logicalModel={undefined}
logicalModels={[]}
permissions={{ permissions={{
_not: { STATUS: { _eq: 'X-Hasura-User-Id' } }, _not: { STATUS: { _eq: 'X-Hasura-User-Id' } },
}} }}

View File

@ -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 { Tables, Operators, Permissions, Comparators } from './types';
import { RowPermissionsProvider } from './RowPermissionsProvider'; import { RowPermissionsProvider } from './RowPermissionsProvider';
import { TypesProvider } from './TypesProvider'; import { TypesProvider } from './TypesProvider';
import { TableProvider } from './TableProvider'; import { TableProvider } from './TableProvider';
import { RootInput } from './RootInput'; import { RootInput } from './RootInput';
import { JsonEditor } from './JsonEditor'; 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 = ({ export const RowPermissionsInput = ({
permissions, permissions,
tables, tables,
table, table,
logicalModel,
logicalModels,
onPermissionsChange, onPermissionsChange,
comparators, comparators,
forbidden,
}: { }: {
permissions: Permissions; permissions: Permissions;
tables: Tables; tables: Tables;
table: Table; table: Table | undefined;
logicalModels: LogicalModelWithSourceName[];
logicalModel: LogicalModel['name'] | undefined;
onPermissionsChange?: (permissions: Permissions) => void; onPermissionsChange?: (permissions: Permissions) => void;
comparators: Comparators; comparators: Comparators;
forbidden?: Feature[];
}) => { }) => {
const operators: Operators = { const operators: Operators = {
boolean: { boolean: {
@ -34,11 +47,15 @@ export const RowPermissionsInput = ({
}, },
}; };
return ( return (
<ForbiddenFeaturesProvider forbidden={forbidden}>
<RootLogicalModelProvider
logicalModel={logicalModel}
logicalModels={logicalModels}
>
<RootTableProvider table={table} tables={tables}>
<RowPermissionsProvider <RowPermissionsProvider
operators={operators} operators={operators}
permissions={permissions} permissions={permissions}
table={table}
tables={tables}
comparators={comparators} comparators={comparators}
onPermissionsChange={onPermissionsChange} onPermissionsChange={onPermissionsChange}
> >
@ -51,5 +68,8 @@ export const RowPermissionsInput = ({
</TableProvider> </TableProvider>
</TypesProvider> </TypesProvider>
</RowPermissionsProvider> </RowPermissionsProvider>
</RootTableProvider>
</RootLogicalModelProvider>
</ForbiddenFeaturesProvider>
); );
}; };

View File

@ -4,8 +4,6 @@ import { Permissions, RowPermissionsState } from './types';
import { updateKey } from './utils/helpers'; import { updateKey } from './utils/helpers';
export const rowPermissionsContext = createContext<RowPermissionsState>({ export const rowPermissionsContext = createContext<RowPermissionsState>({
table: {},
tables: [],
comparators: {}, comparators: {},
operators: {}, operators: {},
permissions: {}, permissions: {},
@ -18,14 +16,9 @@ export const RowPermissionsProvider = ({
children, children,
operators, operators,
permissions, permissions,
table,
tables,
comparators, comparators,
onPermissionsChange, onPermissionsChange,
}: Pick< }: Pick<RowPermissionsState, 'permissions' | 'operators' | 'comparators'> & {
RowPermissionsState,
'permissions' | 'operators' | 'table' | 'tables' | 'comparators'
> & {
children?: React.ReactNode | undefined; children?: React.ReactNode | undefined;
onPermissionsChange?: (permissions: Permissions) => void; onPermissionsChange?: (permissions: Permissions) => void;
}) => { }) => {
@ -79,9 +72,7 @@ export const RowPermissionsProvider = ({
...permissionsState, ...permissionsState,
setValue, setValue,
setKey, setKey,
table,
setPermissions, setPermissions,
tables,
comparators, comparators,
}} }}
> >

View File

@ -4,6 +4,7 @@ import { Table } from '../../../../../hasura-metadata-types';
import { getTableDisplayName } from '../../../../../DatabaseRelationships'; import { getTableDisplayName } from '../../../../../DatabaseRelationships';
import { tableContext } from './TableProvider'; import { tableContext } from './TableProvider';
import { rowPermissionsContext } from './RowPermissionsProvider'; import { rowPermissionsContext } from './RowPermissionsProvider';
import { rootTableContext } from './RootTableProvider';
export function SelectTable({ export function SelectTable({
componentLevelId, componentLevelId,
@ -16,7 +17,8 @@ export function SelectTable({
}) { }) {
const comparatorName = path[path.length - 1]; const comparatorName = path[path.length - 1];
const { table, setTable, setComparator } = useContext(tableContext); const { table, setTable, setComparator } = useContext(tableContext);
const { setValue, tables } = useContext(rowPermissionsContext); const { setValue } = useContext(rowPermissionsContext);
const { tables } = useContext(rootTableContext);
const stringifiedTable = JSON.stringify(table); const stringifiedTable = JSON.stringify(table);
// Sync table name with ColumnsContext table value // Sync table name with ColumnsContext table value
useEffect(() => { useEffect(() => {

View File

@ -1,8 +1,8 @@
import { useState, useContext, useEffect, createContext } from 'react'; import { useState, useContext, useEffect, createContext } from 'react';
import { areTablesEqual } from '../../../../../hasura-metadata-api';
import { Table } from '../../../../../hasura-metadata-types'; import { Table } from '../../../../../hasura-metadata-types';
import { rowPermissionsContext } from './RowPermissionsProvider';
import { Columns, Relationships, TableContext } from './types'; import { Columns, Relationships, TableContext } from './types';
import { rootTableContext } from './RootTableProvider';
import { areTablesEqual } from '../../../../../hasura-metadata-api';
export const tableContext = createContext<TableContext>({ export const tableContext = createContext<TableContext>({
table: {}, table: {},
@ -26,7 +26,7 @@ export const TableProvider = ({
const [comparator, setComparator] = useState<string | undefined>(); const [comparator, setComparator] = useState<string | undefined>();
const [columns, setColumns] = useState<Columns>([]); const [columns, setColumns] = useState<Columns>([]);
const [relationships, setRelationships] = useState<Relationships>([]); const [relationships, setRelationships] = useState<Relationships>([]);
const { tables } = useContext(rowPermissionsContext); const { tables } = useContext(rootTableContext);
// Stringify values to get a stable value for useEffect // Stringify values to get a stable value for useEffect
const stringifiedTable = JSON.stringify(table); const stringifiedTable = JSON.stringify(table);
const stringifiedTables = JSON.stringify(tables); const stringifiedTables = JSON.stringify(tables);

View File

@ -10,6 +10,7 @@ import { rowPermissionsContext } from './RowPermissionsProvider';
import set from 'lodash/set'; import set from 'lodash/set';
import unset from 'lodash/unset'; import unset from 'lodash/unset';
import { getPermissionTypes } from './utils/typeProviderHelpers'; import { getPermissionTypes } from './utils/typeProviderHelpers';
import { rootTableContext } from './RootTableProvider';
export const typesContext = createContext<TypesContext>({ export const typesContext = createContext<TypesContext>({
types: {}, types: {},
@ -22,7 +23,8 @@ export const TypesProvider = ({ children }: { children: React.ReactNode }) => {
const [types, setTypes] = useState<Record<string, { type: PermissionType }>>( const [types, setTypes] = useState<Record<string, { type: PermissionType }>>(
{} {}
); );
const { permissions, tables, table } = useContext(rowPermissionsContext); const { permissions } = useContext(rowPermissionsContext);
const { table, tables } = useContext(rootTableContext);
const setType = useCallback( const setType = useCallback(
({ ({
type, type,

View File

@ -46,8 +46,6 @@ export type RowPermissionsState = {
operators: Operators; operators: Operators;
permissions: Permissions; permissions: Permissions;
comparators: Comparators; comparators: Comparators;
table: Table;
tables: Tables;
setValue: (path: string[], value: any) => void; setValue: (path: string[], value: any) => void;
setKey: (props: { path: string[]; key: any; type: PermissionType }) => void; setKey: (props: { path: string[]; key: any; type: PermissionType }) => void;
setPermissions: (permissions: Permissions) => void; setPermissions: (permissions: Permissions) => void;

View File

@ -8,6 +8,7 @@ import { Table } from '../../../../../../hasura-metadata-types';
import { useContext } from 'react'; import { useContext } from 'react';
import { rowPermissionsContext } from '../RowPermissionsProvider'; import { rowPermissionsContext } from '../RowPermissionsProvider';
import { sourceDataTypes, SourceDataTypes } from './sourceDataTypes'; import { sourceDataTypes, SourceDataTypes } from './sourceDataTypes';
import { rootTableContext } from '../RootTableProvider';
function columnOperators(): Array<Operator> { function columnOperators(): Array<Operator> {
return Object.keys(columnOperatorsInfo).reduce((acc, key) => { return Object.keys(columnOperatorsInfo).reduce((acc, key) => {
@ -136,7 +137,8 @@ export const mapScalarDataType = (
}; };
export function useOperators({ path }: { path: string[] }) { 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 { columns, table } = useContext(tableContext);
const columnName = path[path.length - 2]; const columnName = path[path.length - 2];

View File

@ -69,7 +69,6 @@ export const createDefaultValues = ({
queryType: 'select', queryType: 'select',
filterType: 'none', filterType: 'none',
columns: {}, columns: {},
supportedOperators, supportedOperators,
}; };

View File

@ -67,7 +67,8 @@ export const useFormData = ({
dataSourceName, dataSourceName,
})) as Operator[]; })) as Operator[];
const defaultValues = createDefaultValues({ const defaultValues = {
...createDefaultValues({
queryType, queryType,
roleName, roleName,
dataSourceName, dataSourceName,
@ -77,7 +78,8 @@ export const useFormData = ({
defaultQueryRoot, defaultQueryRoot,
metadataSource, metadataSource,
supportedOperators: supportedOperators ?? [], supportedOperators: supportedOperators ?? [],
}); }),
};
const formData = createFormData({ const formData = createFormData({
dataSourceName, dataSourceName,

View File

@ -1,3 +1,7 @@
import {
LogicalModelWithSource,
NativeQueryWithSource,
} from '../Data/LogicalModels/types';
import { Metadata, Table } from '../hasura-metadata-types'; import { Metadata, Table } from '../hasura-metadata-types';
import * as utils from './utils'; import * as utils from './utils';
@ -31,3 +35,29 @@ export const findTable =
utils.findMetadataTable(dataSourceName, table, m); utils.findMetadataTable(dataSourceName, table, m);
export const resourceVersion = () => (m: Metadata) => m?.resource_version; 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,
};
};

View File

@ -7,4 +7,11 @@ export type LogicalModelField = {
export type LogicalModel = { export type LogicalModel = {
fields: LogicalModelField[]; fields: LogicalModelField[];
name: string; name: string;
select_permissions?: {
permission: {
columns: string[];
filter: Record<string, any>;
};
role: string;
}[];
}; };