console: display collection names and queries from all collections in allowlist

This PR
* Gets all queries from all collections present in allowlist and displays those. (Earlier we were just displaying queries present in "allowed-queries" collection.
* Adds collection names to allow-list section

### Description
fix [4138](https://github.com/hasura/graphql-engine/issues/4138)

### Affected components
- [x] Console

### Solution and Design
<img width="828" alt="Screenshot 2021-01-04 at 12 11 01 PM" src="https://user-images.githubusercontent.com/26903230/103507774-eb495280-4e85-11eb-9ef7-95871fb03edd.png">

### Changelog

- [x] `CHANGELOG.md` is updated with user-facing content relevant to this PR. If no changelog is required, then add the `no-changelog-required` label.

Co-authored-by: Aleksandra Sikora <9019397+beerose@users.noreply.github.com>
GitOrigin-RevId: d96d2aadebeabc00073e028d514db429ee18f187
This commit is contained in:
Abhijeet Singh Khangarot 2021-03-23 20:11:05 +05:30 committed by hasura-bot
parent 0870ceda0d
commit c597efb65e
15 changed files with 666 additions and 560 deletions

View File

@ -12,6 +12,7 @@
- server: fix action custom types failing to parse when mutually recursive
- server: fix MSSQL table name descriptions
- console: allow editing rest endpoints queries and misc ui improvements
- console: display collection names and queries from all collections in allowlist
- cli: match ordering of keys in project metadata files with server metadata
## v2.0.0-alpha.5

View File

@ -1,173 +0,0 @@
import React from 'react';
import AceEditor from 'react-ace';
import styles from './AllowedQueries.scss';
import ExpandableEditor from '../../../Common/Layout/ExpandableEditor/Editor';
import Tooltip from '../../../Common/Tooltip/Tooltip';
import { readFile, parseQueryString } from './utils';
import { showErrorNotification } from '../../Common/Notification';
import { addAllowedQueries } from '../../../../metadata/actions';
class AddAllowedQuery extends React.Component {
constructor(props) {
super(props);
this.state = {
manualQuery: {},
graphqlFile: null,
};
}
render() {
const { dispatch } = this.props;
const { manualQuery, graphqlFile } = this.state;
const handleManualCollapse = () => {
this.setState({ manualQuery: {} });
};
const handleManualSubmit = toggle => {
dispatch(addAllowedQueries([manualQuery], toggle));
};
const handleFileUploadCollapse = () => {};
function handleFileUploadSubmit(toggle) {
const addFileQueries = content => {
try {
const fileQueries = parseQueryString(content);
dispatch(addAllowedQueries(fileQueries, toggle));
} catch (error) {
dispatch(
showErrorNotification('Uploading operations failed', error.message)
);
}
};
readFile(graphqlFile, addFileQueries);
}
const getManualQueryInput = () => {
const getNameInput = () => {
const handleNameChange = e => {
this.setState({
manualQuery: {
...manualQuery,
name: e.target.value,
},
});
};
return (
<div>
<div className={styles.add_mar_bottom_mid}>
<b>Operation name:</b>
</div>
<input
type="text"
className={'form-control input-sm ' + styles.inline_block}
placeholder={'operation_name'}
value={manualQuery.name}
onChange={handleNameChange}
/>
</div>
);
};
const getQueryInput = () => {
const handleQueryChange = val => {
this.setState({
manualQuery: {
...manualQuery,
query: val,
},
});
};
return (
<div>
<div className={styles.add_mar_bottom_mid}>
<b>Operation:</b>
</div>
<AceEditor
data-test="allowed_operation_add"
mode="graphql"
theme="github"
name="allowed_operation_add"
value={manualQuery.query}
minLines={8}
maxLines={100}
width="100%"
showPrintMargin={false}
onChange={handleQueryChange}
/>
</div>
);
};
return (
<div>
<div>{getNameInput()}</div>
<div className={styles.add_mar_top}>{getQueryInput()}</div>
</div>
);
};
const getFileUploadInput = () => {
const handleFileUpload = e => {
const files = e.target.files;
this.setState({ graphqlFile: files[0] });
};
return (
<div>
<div className={styles.add_mar_bottom_mid}>
<b>Graphql File:</b>
<Tooltip message={'.graphql file with operations'} />
</div>
<input
type="file"
className={'form-control input-sm ' + styles.inline_block}
onChange={handleFileUpload}
/>
</div>
);
};
return (
<div>
<h4 className={styles.subheading_text}>
Add new operations to allow-list
</h4>
<div className={styles.subsection}>
<div>
<ExpandableEditor
expandButtonText="Add operation manually"
editorExpanded={getManualQueryInput}
collapseCallback={handleManualCollapse}
property="add-allowed-operation"
service="add-allowed-operation"
saveButtonText="Add"
saveFunc={handleManualSubmit}
/>
</div>
<div className={styles.add_mar_top}>OR</div>
<div className={styles.add_mar_top}>
<ExpandableEditor
expandButtonText="Upload graphql file"
editorExpanded={getFileUploadInput}
collapseCallback={handleFileUploadCollapse}
property="upload-allowed-operations"
service="upload-allowed-operations"
saveButtonText="Upload"
saveFunc={handleFileUploadSubmit}
/>
</div>
</div>
</div>
);
}
}
export default AddAllowedQuery;

View File

@ -0,0 +1,161 @@
import React, { useState } from 'react';
import AceEditor from 'react-ace';
import styles from './AllowedQueries.scss';
import ExpandableEditor from '../../../Common/Layout/ExpandableEditor/Editor';
import Tooltip from '../../../Common/Tooltip/Tooltip';
import { readFile, parseQueryString } from './utils';
import { showErrorNotification } from '../../Common/Notification';
import { addAllowedQueries } from '../../../../metadata/actions';
import { allowedQueriesCollection } from '../../../../metadata/utils';
import { AllowedQueriesCollection } from '../../../../metadata/reducer';
import { Dispatch } from '../../../../types';
const defaultManualQuery: AllowedQueriesCollection = {
name: '',
query: '',
collection: allowedQueriesCollection,
};
type AddAllowedQueryProps = {
dispatch: Dispatch;
};
const AddAllowedQuery: React.FC<AddAllowedQueryProps> = props => {
const { dispatch } = props;
const [manualQuery, setManualQuery] = useState<AllowedQueriesCollection>(
defaultManualQuery
);
const [graphqlFile, setGraphqlFile] = useState<File | null>(null);
const handleManualCollapse = () => {
setManualQuery(defaultManualQuery);
};
const handleManualSubmit = (toggle: () => void) => {
dispatch(addAllowedQueries([manualQuery], toggle));
};
const handleFileUploadCollapse = () => {};
const handleFileUploadSubmit = (toggle: () => void) => {
const addFileQueries = (content: string) => {
try {
const fileQueries = parseQueryString(content);
dispatch(addAllowedQueries(fileQueries, toggle));
} catch (error) {
dispatch(
showErrorNotification('Uploading operations failed', error.message)
);
}
};
readFile(graphqlFile, addFileQueries);
};
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setManualQuery({
...manualQuery,
name: e.target.value,
});
};
const handleQueryChange = (val: string) => {
setManualQuery({
...manualQuery,
query: val,
});
};
const manualQueryInput = () => (
<div>
<div>
<div className={styles.add_mar_bottom_mid}>
<b>Operation name:</b>
</div>
<input
type="text"
className={`form-control input-sm ${styles.inline_block}`}
placeholder="operation_name"
value={manualQuery.name}
onChange={handleNameChange}
/>
</div>
<div className={styles.add_mar_top}>
<div>
<div className={styles.add_mar_bottom_mid}>
<b>Operation:</b>
</div>
<AceEditor
data-test="allowed_operation_add"
mode="graphql"
theme="github"
name="allowed_operation_add"
value={manualQuery.query}
minLines={8}
maxLines={100}
width="100%"
showPrintMargin={false}
onChange={handleQueryChange}
/>
</div>
</div>
</div>
);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
setGraphqlFile(files![0]);
};
const fileUploadInput = () => (
<div>
<div className={styles.add_mar_bottom_mid}>
<b>Graphql File:</b>
<Tooltip message=".graphql file with operations" />
</div>
<input
type="file"
className={`form-control input-sm ${styles.inline_block}`}
onChange={handleFileUpload}
/>
</div>
);
return (
<div>
<h4 className={styles.subheading_text}>
Add new operations to allow-list
</h4>
<div className={styles.subsection}>
<div>
<ExpandableEditor
expandButtonText="Add operation manually"
editorExpanded={manualQueryInput}
collapseCallback={handleManualCollapse}
property="add-allowed-operation"
service="add-allowed-operation"
saveButtonText="Add"
saveFunc={handleManualSubmit}
/>
</div>
<div className={styles.add_mar_top}>OR</div>
<div className={styles.add_mar_top}>
<ExpandableEditor
expandButtonText="Upload graphql file"
editorExpanded={fileUploadInput}
collapseCallback={handleFileUploadCollapse}
property="upload-allowed-operations"
service="upload-allowed-operations"
saveButtonText="Upload"
saveFunc={handleFileUploadSubmit}
/>
</div>
</div>
</div>
);
};
export default AddAllowedQuery;

View File

@ -1,45 +0,0 @@
import React from 'react';
import AllowedQueriesNotes from './AllowedQueriesNotes';
import AddAllowedQuery from './AddAllowedQuery';
import AllowedQueriesList from './AllowedQueriesList';
import styles from './AllowedQueries.scss';
import { getAllowedQueries } from '../../../../metadata/selector';
class AllowedQueries extends React.Component {
render() {
const { dispatch, allowedQueries } = this.props;
return (
<div
className={`${styles.clear_fix} ${styles.padd_left} ${styles.padd_top} ${styles.metadata_wrapper} container-fluid`}
>
<div className={styles.subHeader}>
<h2 className={styles.headerText}>Allow List</h2>
<div className={styles.add_mar_top + ' ' + styles.wd60}>
<AllowedQueriesNotes />
<hr />
<AddAllowedQuery dispatch={dispatch} />
<hr />
<AllowedQueriesList
dispatch={dispatch}
allowedQueries={allowedQueries}
/>
</div>
</div>
</div>
);
}
}
const mapStateToProps = state => {
return {
allowedQueries: getAllowedQueries(state),
};
};
const allowedQueriesConnector = connect =>
connect(mapStateToProps)(AllowedQueries);
export default allowedQueriesConnector;

View File

@ -0,0 +1,51 @@
import React from 'react';
import AllowedQueriesNotes from './AllowedQueriesNotes';
import AddAllowedQuery from './AddAllowedQuery';
import AllowedQueriesList from './AllowedQueriesList';
import styles from './AllowedQueries.scss';
import { getAllowedQueries } from '../../../../metadata/selector';
import { Dispatch, ReduxState } from '../../../../types';
import { mapDispatchToPropsEmpty } from '../../../Common/utils/reactUtils';
import { AllowedQueriesCollection } from '../../../../metadata/reducer';
interface Props {
dispatch: Dispatch;
allowedQueries: AllowedQueriesCollection[];
}
const AllowedQueries: React.FC<Props> = props => {
const { dispatch, allowedQueries } = props;
return (
<div
className={`${styles.clear_fix} ${styles.padd_left} ${styles.padd_top} ${styles.metadata_wrapper} container-fluid`}
>
<div className={styles.subHeader}>
<h2 className={styles.headerText}>Allow List</h2>
<div className={`${styles.add_mar_top} ${styles.wd60}`}>
<AllowedQueriesNotes />
<hr />
<AddAllowedQuery dispatch={dispatch} />
<hr />
<AllowedQueriesList
dispatch={dispatch}
allowedQueries={allowedQueries}
/>
</div>
</div>
</div>
);
};
const mapStateToProps = (state: ReduxState) => {
return {
allowedQueries: getAllowedQueries(state),
};
};
const allowedQueriesConnector = (connect: any) =>
connect(mapStateToProps, mapDispatchToPropsEmpty)(AllowedQueries);
export default allowedQueriesConnector;

View File

@ -1,177 +0,0 @@
import React from 'react';
import AceEditor from 'react-ace';
import ExpandableEditor from '../../../Common/Layout/ExpandableEditor/Editor';
import styles from './AllowedQueries.scss';
import Button from '../../../Common/Button/Button';
import { getConfirmation } from '../../../Common/utils/jsUtils';
import {
updateAllowedQuery,
deleteAllowedQuery,
deleteAllowList,
} from '../../../../metadata/actions';
class AllowedQueriesList extends React.Component {
constructor(props) {
super(props);
this.state = {
modifiedQueries: {},
};
}
render() {
const { allowedQueries, dispatch } = this.props;
const { modifiedQueries } = this.state;
const getQueryList = () => {
if (allowedQueries.length === 0) {
return <div>No operations in allow-list yet</div>;
}
return allowedQueries.map((query, i) => {
const queryName = query.name;
const collapsedLabel = () => (
<div>
<b>{queryName}</b>
</div>
);
const expandedLabel = collapsedLabel;
const queryEditorExpanded = () => {
const modifiedQuery = modifiedQueries[queryName] || { ...query };
const handleNameChange = e => {
const newModifiedQueries = { ...modifiedQueries };
newModifiedQueries[queryName].name = e.target.value;
this.setState({ modifiedQueries: newModifiedQueries });
};
const handleQueryChange = val => {
const newModifiedQueries = { ...modifiedQueries };
newModifiedQueries[queryName].query = val;
this.setState({ modifiedQueries: newModifiedQueries });
};
return (
<div>
<div>
<div className={styles.add_mar_bottom_mid}>
<b>Operation name:</b>
</div>
<input
type="text"
className={'form-control input-sm ' + styles.inline_block}
value={modifiedQuery.name}
placeholder={'operation_name'}
onChange={handleNameChange}
/>
</div>
<div className={styles.add_mar_top}>
<div className={styles.add_mar_bottom_mid}>
<b>Operation:</b>
</div>
<AceEditor
data-test="allowed_operation_editor"
mode="graphql"
theme="github"
name="allowed_operation_editor"
value={modifiedQuery.query}
minLines={8}
maxLines={100}
width="100%"
showPrintMargin={false}
onChange={handleQueryChange}
/>
</div>
</div>
);
};
const editorExpandCallback = () => {
const newModifiedQueries = { ...modifiedQueries };
newModifiedQueries[queryName] = { ...query };
this.setState({ modifiedQueries: newModifiedQueries });
};
const editorCollapseCallback = () => {
const newModifiedQueries = { ...modifiedQueries };
delete newModifiedQueries[queryName];
this.setState({ modifiedQueries: newModifiedQueries });
};
const onSubmit = () => {
dispatch(updateAllowedQuery(queryName, modifiedQueries[queryName]));
};
const onDelete = () => {
const confirmMessage = `This will delete the operation "${queryName}" from the allow-list`;
const isOk = getConfirmation(confirmMessage);
if (isOk) {
const isLastQuery = allowedQueries.length === 1;
dispatch(deleteAllowedQuery(queryName, isLastQuery));
}
};
return (
<div key={queryName}>
<ExpandableEditor
editorExpanded={queryEditorExpanded}
property={`query-${i}`}
service="modify-allowed-operation"
saveFunc={onSubmit}
removeFunc={onDelete}
collapsedClass={styles.display_flex}
expandedLabel={expandedLabel}
collapsedLabel={collapsedLabel}
expandCallback={editorExpandCallback}
collapseCallback={editorCollapseCallback}
/>
</div>
);
});
};
const getDeleteAllBtn = () => {
const handleDeleteAll = () => {
const confirmMessage =
'This will delete all operations from the allow-list';
const isOk = getConfirmation(confirmMessage, true);
if (isOk) {
dispatch(deleteAllowList());
}
};
return (
<Button
size="xs"
onClick={handleDeleteAll}
disabled={allowedQueries.length === 0}
>
Delete all
</Button>
);
};
return (
<div>
<h4 className={styles.subheading_text}>
Allow List
<span className={styles.add_mar_left}>{getDeleteAllBtn()}</span>
</h4>
<div className={styles.subsection}>{getQueryList()}</div>
</div>
);
}
}
export default AllowedQueriesList;

View File

@ -0,0 +1,177 @@
import React, { useState } from 'react';
import AceEditor from 'react-ace';
import ExpandableEditor from '../../../Common/Layout/ExpandableEditor/Editor';
import styles from './AllowedQueries.scss';
import Button from '../../../Common/Button/Button';
import { getConfirmation } from '../../../Common/utils/jsUtils';
import {
updateAllowedQuery,
deleteAllowedQuery,
deleteAllowList,
} from '../../../../metadata/actions';
import { AllowedQueriesCollection } from '../../../../metadata/reducer';
import { Dispatch } from '../../../../types';
import { getCollectionNames, checkLastQuery } from './utils';
type AllowedQueriesListProps = {
dispatch: Dispatch;
allowedQueries: AllowedQueriesCollection[];
};
type ModifiedQuery = Record<string, AllowedQueriesCollection>;
const AllowedQueriesList: React.FC<AllowedQueriesListProps> = props => {
const [modifiedQueries, setModifiedQueries] = useState<ModifiedQuery>({});
const { allowedQueries, dispatch } = props;
const getQueryList = () => {
if (allowedQueries.length === 0) {
return <div>No operations in allow-list yet</div>;
}
return allowedQueries.map((query, i) => {
const queryName = query.name;
const collectionName = query.collection;
const collapsedLabel = () => (
<div>
<b>{queryName} </b>
<i>- {collectionName}</i>
</div>
);
const expandedLabel = collapsedLabel;
const queryEditorExpanded = () => {
const modifiedQuery = modifiedQueries[queryName] || { ...query };
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newModifiedQueries = { ...modifiedQueries };
newModifiedQueries[queryName].name = e.target.value;
setModifiedQueries(newModifiedQueries);
};
const handleQueryChange = (val: string) => {
const newModifiedQueries = { ...modifiedQueries };
newModifiedQueries[queryName].query = val;
setModifiedQueries(newModifiedQueries);
};
return (
<div>
<div>
<div className={styles.add_mar_bottom_mid}>
<b>Operation name:</b>
</div>
<input
type="text"
className={`form-control input-sm ${styles.inline_block}`}
value={modifiedQuery.name}
placeholder="operation_name"
onChange={handleNameChange}
/>
</div>
<div className={styles.add_mar_top}>
<div className={styles.add_mar_bottom_mid}>
<b>Operation:</b>
</div>
<AceEditor
data-test="allowed_operation_editor"
mode="graphql"
theme="github"
name="allowed_operation_editor"
value={modifiedQuery.query}
minLines={8}
maxLines={100}
width="100%"
showPrintMargin={false}
onChange={handleQueryChange}
/>
</div>
</div>
);
};
const editorExpandCallback = () => {
const newModifiedQueries = { ...modifiedQueries };
newModifiedQueries[queryName] = { ...query };
setModifiedQueries(newModifiedQueries);
};
const editorCollapseCallback = () => {
const newModifiedQueries = { ...modifiedQueries };
delete newModifiedQueries[queryName];
setModifiedQueries(newModifiedQueries);
};
const onSubmit = () => {
dispatch(
updateAllowedQuery(
queryName,
modifiedQueries[queryName],
collectionName
)
);
};
const onDelete = () => {
const confirmMessage = `This will delete the operation "${queryName}" from the allow-list`;
const isOk = getConfirmation(confirmMessage);
if (isOk) {
const isLastQuery = checkLastQuery(collectionName, allowedQueries);
dispatch(deleteAllowedQuery(queryName, isLastQuery, collectionName));
}
};
return (
<div key={queryName}>
<ExpandableEditor
editorExpanded={queryEditorExpanded}
property={`query-${i}`}
service="modify-allowed-operation"
saveFunc={onSubmit}
removeFunc={onDelete}
collapsedClass={styles.display_flex}
expandedLabel={expandedLabel}
collapsedLabel={collapsedLabel}
expandCallback={editorExpandCallback}
collapseCallback={editorCollapseCallback}
/>
</div>
);
});
};
const handleDeleteAll = () => {
const confirmMessage =
'This will delete all operations from the allow-list';
const isOk = getConfirmation(confirmMessage, true);
const collectionNames = getCollectionNames(allowedQueries);
if (isOk) {
dispatch(deleteAllowList(collectionNames));
}
};
return (
<div>
<h4 className={styles.subheading_text}>
Allow List
<span className={styles.add_mar_left}>
<Button
size="xs"
onClick={handleDeleteAll}
disabled={allowedQueries.length === 0}
>
Delete all
</Button>
</span>
</h4>
<div className={styles.subsection}>{getQueryList()}</div>
</div>
);
};
export default AllowedQueriesList;

View File

@ -1,39 +0,0 @@
import React from 'react';
import styles from './AllowedQueries.scss';
class AllowedQueriesNotes extends React.Component {
render() {
return (
<div>
<div>
If GraphQL Engine is started with the{' '}
<code>HASURA_GRAPHQL_ENABLE_ALLOWLIST</code> env var or the{' '}
<code>--enable-allowlist</code> flag set to <i>true</i>, only
operations added to the allow-list will be allowed to be
executed.&nbsp;
<a
href="https://hasura.io/docs/latest/graphql/core/deployment/allow-list.html"
target="_blank"
rel="noopener noreferrer"
>
<i>(Read more)</i>
</a>
</div>
<div className={styles.add_mar_top}>
<b>Notes</b>
<div className={styles.subsection}>
<ul
className={styles.ul_left_small + ' ' + styles.add_mar_top_small}
>
<li>
All allowed operations need to have a unique name for reference
</li>
</ul>
</div>
</div>
</div>
);
}
}
export default AllowedQueriesNotes;

View File

@ -0,0 +1,34 @@
import React from 'react';
import styles from './AllowedQueries.scss';
const AllowedQueriesNotes: React.FC = () => {
return (
<div>
<div>
If GraphQL Engine is started with the{' '}
<code>HASURA_GRAPHQL_ENABLE_ALLOWLIST</code> env var or the{' '}
<code>--enable-allowlist</code> flag set to <i>true</i>, only operations
added to the allow-list will be allowed to be executed.&nbsp;
<a
href="https://hasura.io/docs/latest/graphql/core/deployment/allow-list.html"
target="_blank"
rel="noopener noreferrer"
>
<i>(Read more)</i>
</a>
</div>
<div className={styles.add_mar_top}>
<b>Notes</b>
<div className={styles.subsection}>
<ul className={`${styles.ul_left_small} ${styles.add_mar_top_small}`}>
<li>
All allowed operations need to have a unique name for reference
</li>
</ul>
</div>
</div>
</div>
);
};
export default AllowedQueriesNotes;

View File

@ -1,99 +0,0 @@
import { parse, print, visit } from 'graphql';
export const readFile = (file, callback) => {
const reader = new FileReader();
reader.onload = event => {
const content = event.target.result;
callback(content);
};
reader.onerror = event => {
console.error('File could not be read! Code ' + event.target.error.code);
};
reader.readAsText(file);
};
function recurQueryDef(queryDef, fragments, definitionHash) {
visit(queryDef, {
FragmentSpread(node) {
fragments.add(node.name.value);
recurQueryDef(definitionHash[node.name.value], fragments, definitionHash);
},
});
}
const getQueryFragments = (queryDef, definitionHash = {}) => {
const fragments = new Set();
recurQueryDef(queryDef, fragments, definitionHash);
return [...fragments];
};
const getQueryString = (queryDef, fragmentDefs, definitionHash = {}) => {
let queryString = print(queryDef);
const queryFragments = getQueryFragments(queryDef, definitionHash);
queryFragments.forEach(qf => {
const fragmentDef = fragmentDefs.find(fd => fd.name.value === qf);
if (fragmentDef) {
queryString += '\n\n' + print(fragmentDef);
}
});
return queryString;
};
export const parseQueryString = queryString => {
const queries = [];
let parsedQueryString;
try {
parsedQueryString = parse(queryString);
} catch (ex) {
throw new Error('Parsing operation failed');
}
const definitionHash = (parsedQueryString.definitions || []).reduce(
(defObj, queryObj) => {
defObj[queryObj.name.value] = queryObj;
return defObj;
},
{}
);
const queryDefs = parsedQueryString.definitions.filter(
def => def.kind === 'OperationDefinition'
);
const fragmentDefs = parsedQueryString.definitions.filter(
def => def.kind === 'FragmentDefinition'
);
queryDefs.forEach(queryDef => {
if (!queryDef.name) {
throw new Error(`Operation without name found: ${print(queryDef)}`);
}
const query = {
name: queryDef.name.value,
query: getQueryString(queryDef, fragmentDefs, definitionHash),
};
queries.push(query);
});
const queryNames = queries.map(q => q.name);
const duplicateNames = queryNames.filter(
(q, i) => queryNames.indexOf(q) !== i
);
if (duplicateNames.length > 0) {
throw new Error(
`Operations with duplicate names found: ${duplicateNames.join(', ')}`
);
}
return queries;
};

View File

@ -0,0 +1,150 @@
import { parse, print, visit, DefinitionNode } from 'graphql';
import { AllowedQueriesCollection } from '../../../../metadata/reducer';
export type NewDefinitionNode = DefinitionNode & {
name?: {
value: string;
};
};
export const readFile = (
file: File | null,
callback: (content: string) => void
) => {
const reader = new FileReader();
reader.onload = event => {
const content = event.target!.result as string;
callback(content);
};
reader.onerror = event => {
console.error(`File could not be read! Code ${event.target!.error!.code}`);
};
if (file) reader.readAsText(file);
};
const recurQueryDef = (
queryDef: NewDefinitionNode,
fragments: Set<string>,
definitionHash: Record<string, any>
) => {
visit(queryDef, {
FragmentSpread(node) {
fragments.add(node.name.value);
recurQueryDef(definitionHash[node.name.value], fragments, definitionHash);
},
});
};
const getQueryFragments = (
queryDef: NewDefinitionNode,
definitionHash: Record<string, any> = {}
) => {
const fragments = new Set<string>();
recurQueryDef(queryDef, fragments, definitionHash);
return [...Array.from(fragments)];
};
const getQueryString = (
queryDef: NewDefinitionNode,
fragmentDefs: NewDefinitionNode[],
definitionHash: Record<string, any> = {}
) => {
let queryString = print(queryDef);
const queryFragments = getQueryFragments(queryDef, definitionHash);
queryFragments.forEach(qf => {
// eslint-disable-next-line array-callback-return
const fragmentDef = fragmentDefs.find(fd => {
if (fd.name) return fd.name.value === qf;
});
if (fragmentDef) {
queryString += `\n\n${print(fragmentDef)}`;
}
});
return queryString;
};
export const parseQueryString = (queryString: string) => {
const queries: { name: string; query: string }[] = [];
let parsedQueryString;
try {
parsedQueryString = parse(queryString);
} catch (ex) {
throw new Error('Parsing operation failed');
}
const definitions: NewDefinitionNode[] = [...parsedQueryString.definitions];
const definitionHash = (definitions || []).reduce(
(defObj: Record<string, NewDefinitionNode>, queryObj) => {
if (queryObj.name) defObj[queryObj.name.value] = queryObj;
return defObj;
},
{}
);
const queryDefs = definitions.filter(
def => def.kind === 'OperationDefinition'
);
const fragmentDefs = definitions.filter(
def => def.kind === 'FragmentDefinition'
);
queryDefs.forEach(queryDef => {
if (!queryDef.name) {
throw new Error(`Operation without name found: ${print(queryDef)}`);
}
const query = {
name: queryDef.name.value,
query: getQueryString(queryDef, fragmentDefs, definitionHash),
};
queries.push(query);
});
const queryNames = queries.map(q => q.name);
const duplicateNames = queryNames.filter(
(q, i) => queryNames.indexOf(q) !== i
);
if (duplicateNames.length > 0) {
throw new Error(
`Operations with duplicate names found: ${duplicateNames.join(', ')}`
);
}
return queries;
};
export const getQueriesInCollection = (
collectionName: string,
allowedQueries: AllowedQueriesCollection[]
) => {
const queries: AllowedQueriesCollection[] = [];
allowedQueries.forEach(query => {
if (query.collection === collectionName) {
queries.push(query);
}
});
return queries;
};
export const checkLastQuery = (
collectionName: string,
queries: AllowedQueriesCollection[]
) => {
return getQueriesInCollection(collectionName, queries).length === 1;
};
// Missing feature in typescript https://stackoverflow.com/questions/33464504/using-spread-syntax-and-new-set-with-typescript/33464709
export const getCollectionNames = (queries: AllowedQueriesCollection[]) => {
return Array.from(new Set(queries.map(query => query.collection)));
};

View File

@ -91,7 +91,7 @@ export interface UpdateAllowedQuery {
type: 'Metadata/UPDATE_ALLOWED_QUERY';
data: {
queryName: string;
newQuery: { name: string; query: string };
newQuery: { name: string; query: string; collection: string };
};
}
export interface DeleteAllowedQuery {
@ -704,20 +704,27 @@ export const dropInconsistentObjects = (
export const updateAllowedQuery = (
queryName: string,
newQuery: { name: string; query: string }
newQuery: { name: string; query: string },
collectionName: string
): Thunk<void, MetadataActions> => {
return (dispatch, getState) => {
const upQuery = updateAllowedQueryQuery(queryName, newQuery);
const upQuery = updateAllowedQueryQuery(
queryName,
newQuery,
collectionName
);
const migrationName = `update_allowed_query`;
const requestMsg = 'Updating allowed query...';
const successMsg = 'Updated allow-list query';
const errorMsg = 'Updating allow-list query failed';
const updatedQuery = { ...newQuery, collection: collectionName };
const onSuccess = () => {
dispatch({
type: 'Metadata/UPDATE_ALLOWED_QUERY',
data: { queryName, newQuery },
data: { queryName, newQuery: updatedQuery },
});
};
@ -740,12 +747,13 @@ export const updateAllowedQuery = (
export const deleteAllowedQuery = (
queryName: string,
isLastQuery: boolean
isLastQuery: boolean,
collectionName: string
): Thunk<void, MetadataActions> => {
return (dispatch, getState) => {
const upQuery = isLastQuery
? deleteAllowListQuery()
: deleteAllowedQueryQuery(queryName);
? deleteAllowListQuery(collectionName)
: deleteAllowedQueryQuery(queryName, collectionName);
const migrationName = `delete_allowed_query`;
const requestMsg = 'Deleting allowed query...';
@ -773,9 +781,19 @@ export const deleteAllowedQuery = (
};
};
export const deleteAllowList = (): Thunk<void, MetadataActions> => {
export const deleteAllowList = (
collectionNames: string[]
): Thunk<void, MetadataActions> => {
return (dispatch, getState) => {
const upQuery = deleteAllowListQuery();
const upQueries: {
type: string;
args: { collection: string; cascade: boolean };
}[] = [];
collectionNames.forEach(collectionName => {
upQueries.push(deleteAllowListQuery(collectionName));
});
const migrationName = 'delete_allow_list';
const requestMsg = 'Deleting allow list...';
const successMsg = 'Deleted all queries from allow-list';
@ -790,7 +808,7 @@ export const deleteAllowList = (): Thunk<void, MetadataActions> => {
makeMigrationCall(
dispatch,
getState,
[upQuery],
upQueries,
undefined,
migrationName,
onSuccess,

View File

@ -1,6 +1,12 @@
import { MetadataActions } from './actions';
import { QueryCollection, HasuraMetadataV3, InheritedRole } from './types';
import { allowedQueriesCollection } from './utils';
import { HasuraMetadataV3, CollectionName, InheritedRole } from './types';
import { setAllowedQueries } from './utils';
export type AllowedQueriesCollection = {
name: string;
query: string;
collection: CollectionName;
};
type MetadataState = {
metadataObject: null | HasuraMetadataV3;
@ -8,7 +14,7 @@ type MetadataState = {
loading: boolean;
inconsistentObjects: any[];
ongoingRequest: boolean; // deprecate
allowedQueries: QueryCollection[];
allowedQueries: AllowedQueriesCollection[];
inheritedRoles: InheritedRole[];
};
@ -31,10 +37,10 @@ export const metadataReducer = (
return {
...state,
metadataObject: action.data,
allowedQueries:
action.data?.query_collections?.find(
query => query.name === allowedQueriesCollection
)?.definition.queries || [],
allowedQueries: setAllowedQueries(
action.data?.query_collections,
action.data?.allowlist
),
inheritedRoles: action.data?.inherited_roles,
loading: false,
error: null,

View File

@ -923,7 +923,8 @@ export interface HasuraMetadataV3 {
actions?: Action[];
custom_types?: CustomTypes;
cron_triggers?: CronTrigger[];
query_collections: QueryCollectionEntry[];
query_collections?: QueryCollectionEntry[];
allowlist?: AllowList[];
inherited_roles: InheritedRole[];
rest_endpoints?: RestEndpointEntry[];
}

View File

@ -3,22 +3,56 @@ import {
getReloadMetadataQuery,
getReloadRemoteSchemaCacheQuery,
} from './queryUtils';
import { HasuraMetadataV3 } from './types';
import { AllowList, QueryCollectionEntry, HasuraMetadataV3 } from './types';
import { AllowedQueriesCollection } from './reducer';
export const allowedQueriesCollection = 'allowed-queries';
export const deleteAllowedQueryQuery = (queryName: string) => ({
export const findAllowedQueryCollections = (
collectionName: string,
allowList: AllowList[]
) => {
return allowList.find(
allowedCollection => collectionName === allowedCollection.collection
);
};
export const setAllowedQueries = (
allQueryCollections?: QueryCollectionEntry[],
allowlist?: AllowList[]
): AllowedQueriesCollection[] => {
if (!allQueryCollections || !allowlist) return [];
const allowedQueryCollections = allQueryCollections.filter(query =>
findAllowedQueryCollections(query.name, allowlist)
);
const allowedQueries: AllowedQueriesCollection[] = [];
allowedQueryCollections.forEach(collection => {
collection.definition.queries.forEach(query => {
allowedQueries.push({ ...query, collection: collection.name });
});
});
return allowedQueries;
};
export const deleteAllowedQueryQuery = (
queryName: string,
collectionName = allowedQueriesCollection
) => ({
type: 'drop_query_from_collection',
args: {
collection_name: allowedQueriesCollection,
collection_name: collectionName,
query_name: queryName,
},
});
export const addAllowedQuery = (query: { name: string; query: string }) => ({
export const addAllowedQuery = (
query: { name: string; query: string },
collectionName = allowedQueriesCollection
) => ({
type: 'add_query_to_collection',
args: {
collection_name: allowedQueriesCollection,
collection_name: collectionName,
query_name: query.name,
query: query.query,
},
@ -26,16 +60,22 @@ export const addAllowedQuery = (query: { name: string; query: string }) => ({
export const updateAllowedQueryQuery = (
queryName: string,
newQuery: { name: string; query: string }
newQuery: { name: string; query: string },
collectionName = allowedQueriesCollection
) => ({
type: 'bulk',
args: [deleteAllowedQueryQuery(queryName), addAllowedQuery(newQuery)],
args: [
deleteAllowedQueryQuery(queryName, collectionName),
addAllowedQuery(newQuery, collectionName),
],
});
export const deleteAllowListQuery = () => ({
export const deleteAllowListQuery = (
collectionName = allowedQueriesCollection
) => ({
type: 'drop_query_collection',
args: {
collection: allowedQueriesCollection,
collection: collectionName,
cascade: true,
},
});