fix: validate query populate

This commit is contained in:
Ben Irvin 2024-02-15 09:24:45 +01:00 committed by GitHub
parent 2c180844b3
commit 5c8ef69f82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 204 additions and 230 deletions

View File

@ -11,6 +11,7 @@ module.exports = (fixtures) => {
password: 'Password1234',
misc: 2,
relations: [relation[0].id, relation[1].id],
private_relations: [relation[0].id, relation[1].id],
componentA: {
name: 'Component A Name A',
name_private: 'Private Component A Name A',
@ -44,6 +45,7 @@ module.exports = (fixtures) => {
password: 'Password5678',
misc: 3,
relations: [relation[1].id],
private_relations: [relation[1].id],
componentA: {
name: 'Component A Name B',
name_private: 'Private Component A Name B',

View File

@ -31,6 +31,13 @@ module.exports = {
target: 'api::relation.relation',
targetAttribute: 'documents',
},
private_relations: {
type: 'relation',
private: true,
relation: 'manyToMany',
target: 'api::relation.relation',
targetAttribute: 'documents',
},
componentA: {
type: 'component',
component: 'default.component-a',

View File

@ -933,6 +933,13 @@ describe('Core API - Validate', () => {
});
});
it('Does not populate private relation', async () => {
const populate = { private_relations: true };
const res = await rq.get('/api/documents', { qs: { populate } });
expect(res.status).toBe(400);
});
it.todo('Populates a nested relation');
it.todo('Populates a media');

View File

@ -21,7 +21,7 @@ import { contentTypes, traverseEntity, sanitize, pipeAsync, traverse } from '@st
import { ADMIN_USER_ALLOWED_FIELDS } from '../../../domain/user';
const {
visitors: { removePassword },
visitors: { removePassword, expandWildcardPopulate },
} = sanitize;
const {
@ -86,6 +86,7 @@ export default ({ action, ability, model }: any) => {
);
const sanitizePopulate = pipeAsync(
traverse.traverseQueryPopulate(expandWildcardPopulate, { schema }),
traverse.traverseQueryPopulate(removeDisallowedFields(permittedFields), { schema }),
traverse.traverseQueryPopulate(omitDisallowedAdminUserFields, { schema }),
traverse.traverseQueryPopulate(omitHiddenFields, { schema }),

View File

@ -37,8 +37,11 @@ const COMPONENT_FIELDS = ['__component'];
const STATIC_FIELDS = [ID_ATTRIBUTE, DOC_ID_ATTRIBUTE];
const throwInvalidParam = ({ key }: any) => {
throw new ValidationError(`Invalid parameter ${key}`);
const throwInvalidParam = ({ key, path }: { key: string; path?: string | null }) => {
const msg =
path && path !== key ? `Invalid parameter ${key} at ${path}` : `Invalid parameter ${key}`;
throw new ValidationError(msg);
};
export default ({ action, ability, model }: any) => {
@ -55,9 +58,9 @@ export default ({ action, ability, model }: any) => {
traverse.traverseQueryFilters(throwDisallowedAdminUserFields, { schema }),
traverse.traverseQueryFilters(throwPassword, { schema }),
traverse.traverseQueryFilters(
({ key, value }) => {
({ key, value, path }) => {
if (isObject(value) && isEmpty(value)) {
throwInvalidParam({ key });
throwInvalidParam({ key, path: path.attribute });
}
},
{ schema }
@ -69,9 +72,9 @@ export default ({ action, ability, model }: any) => {
traverse.traverseQuerySort(throwDisallowedAdminUserFields, { schema }),
traverse.traverseQuerySort(throwPassword, { schema }),
traverse.traverseQuerySort(
({ key, attribute, value }) => {
({ key, attribute, value, path }) => {
if (!isScalarAttribute(attribute) && isEmpty(value)) {
throwInvalidParam({ key });
throwInvalidParam({ key, path: path.attribute });
}
},
{ schema }
@ -83,6 +86,13 @@ export default ({ action, ability, model }: any) => {
traverse.traverseQueryFields(throwPassword, { schema })
);
const validatePopulate = pipeAsync(
traverse.traverseQueryPopulate(throwDisallowedFields(permittedFields), { schema }),
traverse.traverseQueryPopulate(throwDisallowedAdminUserFields, { schema }),
traverse.traverseQueryPopulate(throwHiddenFields, { schema }),
traverse.traverseQueryPopulate(throwPassword, { schema })
);
return async (query: any) => {
if (query.filters) {
await validateFilters(query.filters);
@ -96,6 +106,11 @@ export default ({ action, ability, model }: any) => {
await validateFields(query.fields);
}
// a wildcard is always valid; its conversion will be handled by the entity service and can be optimized with sanitizer
if (query.populate && query.populate !== '*') {
await validatePopulate(query.populate);
}
return true;
};
};
@ -165,20 +180,20 @@ export default ({ action, ability, model }: any) => {
/**
* Visitor used to remove hidden fields from the admin API responses
*/
const throwHiddenFields = ({ key, schema }: any) => {
const throwHiddenFields = ({ key, schema, path }: any) => {
const isHidden = getOr(false, ['config', 'attributes', key, 'hidden'], schema);
if (isHidden) {
throwInvalidParam({ key });
throwInvalidParam({ key, path: path.attribute });
}
};
/**
* Visitor used to omit disallowed fields from the admin users entities & avoid leaking sensitive information
*/
const throwDisallowedAdminUserFields = ({ key, attribute, schema }: any) => {
const throwDisallowedAdminUserFields = ({ key, attribute, schema, path }: any) => {
if (schema.uid === 'admin::user' && attribute && !ADMIN_USER_ALLOWED_FIELDS.includes(key)) {
throwInvalidParam({ key });
throwInvalidParam({ key, path: path.attribute });
}
};

View File

@ -1,22 +1,7 @@
import { traverseQueryPopulate } from '../traverse';
describe('traverseQueryPopulate', () => {
test('should return an empty object incase no populatable field exists', async () => {
const query = await traverseQueryPopulate(jest.fn(), {
schema: {
kind: 'collectionType',
attributes: {
title: {
type: 'string',
},
},
},
})('*');
expect(query).toEqual({});
});
test('should return all populatable fields', async () => {
test('should not modify wildcard', async () => {
const strapi = {
getModel: jest.fn((uid) => {
return {
@ -33,7 +18,6 @@ describe('traverseQueryPopulate', () => {
get: jest.fn(() => ({
columnToAttribute: {
address: 'address',
some: 'some',
},
})),
},
@ -63,7 +47,7 @@ describe('traverseQueryPopulate', () => {
},
})('*');
expect(query).toEqual({ address: true, some: true });
expect(query).toEqual('*');
});
test('should return only selected populatable field', async () => {
@ -114,151 +98,4 @@ describe('traverseQueryPopulate', () => {
expect(query).toEqual('address');
});
test('should populate dynamiczone', async () => {
const strapi = {
getModel: jest.fn((uid) => {
return {
uid,
attributes: {
street: {
type: 'string',
},
},
};
}),
db: {
metadata: {
get: jest.fn(() => ({
columnToAttribute: {
address: 'address',
},
})),
},
},
} as any;
global.strapi = strapi;
const query = await traverseQueryPopulate(jest.fn(), {
schema: {
kind: 'collectionType',
attributes: {
title: {
type: 'string',
},
address: {
type: 'relation',
relation: 'oneToOne',
target: 'api::address.address',
},
some: {
type: 'relation',
relation: 'ManyToMany',
target: 'api::some.some',
},
zone: {
type: 'dynamiczone',
components: ['blog.test-como', 'some.another-como'],
},
},
},
})('*');
expect(query).toEqual({
address: true,
some: true,
zone: true,
});
});
test('should deep populate dynamiczone components', async () => {
const strapi = {
getModel: jest.fn((uid) => {
if (uid === 'blog.test-como') {
return {
uid,
attributes: {
street: {
type: 'string',
},
address: {
type: 'relation',
relation: 'oneToOne',
target: 'api::address.address',
},
},
};
}
if (uid === 'some.another-como') {
return {
uid,
attributes: {
street: {
type: 'string',
},
some: {
type: 'relation',
relation: 'ManyToMany',
target: 'api::some.some',
},
},
};
}
return {
uid,
attributes: {
street: {
type: 'string',
},
},
};
}),
db: {
metadata: {
get: jest.fn(() => ({
columnToAttribute: {
address: 'address',
},
})),
},
},
} as any;
global.strapi = strapi;
const query = await traverseQueryPopulate(jest.fn(), {
schema: {
kind: 'collectionType',
attributes: {
title: {
type: 'string',
},
address: {
type: 'relation',
relation: 'oneToOne',
target: 'api::address.address',
},
some: {
type: 'relation',
relation: 'ManyToMany',
target: 'api::some.some',
},
zone: {
type: 'dynamiczone',
components: ['blog.test-como', 'some.another-como'],
},
},
},
})({ zone: { populate: '*' } });
expect(query).toEqual({
zone: {
populate: {
address: true,
some: true,
},
},
});
});
});

View File

@ -16,6 +16,7 @@ import {
removePrivate,
removeDynamicZones,
removeMorphToRelations,
expandWildcardPopulate,
} from './visitors';
import { isOperator } from '../operators';
@ -164,6 +165,7 @@ const defaultSanitizePopulate = curry((schema: Model, populate: unknown) => {
throw new Error('Missing schema in defaultSanitizePopulate');
}
return pipeAsync(
traverseQueryPopulate(expandWildcardPopulate, { schema }),
traverseQueryPopulate(
async ({ key, value, schema, attribute }, { set }) => {
if (attribute) {
@ -181,6 +183,10 @@ const defaultSanitizePopulate = curry((schema: Model, populate: unknown) => {
if (key === 'fields') {
set(key, await defaultSanitizeFields(schema, value));
}
if (key === 'populate') {
set(key, await defaultSanitizePopulate(schema, value));
}
},
{ schema }
),

View File

@ -0,0 +1,17 @@
import type { Visitor } from '../../traverse/factory';
const visitor: Visitor = ({ schema, key, value }, { set }) => {
if (key === '' && value === '*') {
const { attributes } = schema;
const newPopulateQuery = Object.entries(attributes)
.filter(([, attribute]) =>
['relation', 'component', 'media', 'dynamiczone'].includes(attribute.type)
)
.reduce<Record<string, true>>((acc, [key]) => ({ ...acc, [key]: true }), {});
set('', newPopulateQuery);
}
};
export default visitor;

View File

@ -5,3 +5,4 @@ export { default as removeMorphToRelations } from './remove-morph-to-relations';
export { default as removeDynamicZones } from './remove-dynamic-zones';
export { default as removeDisallowedFields } from './remove-disallowed-fields';
export { default as removeRestrictedFields } from './remove-restricted-fields';
export { default as expandWildcardPopulate } from './expand-wildcard-populate';

View File

@ -51,7 +51,7 @@ interface Interceptor<T = unknown> {
interface ParseUtils<T> {
transform(data: T): unknown;
remove(key: string, data: T): unknown;
set(key: string, valeu: unknown, data: T): unknown;
set(key: string, value: unknown, data: T): unknown;
keys(data: T): string[];
get(key: string, data: T): unknown;
}

View File

@ -6,7 +6,7 @@ const isStringArray = (value: unknown): value is string[] =>
isArray(value) && value.every(isString);
const fields = traverseFactory()
// Interecept array of strings
// Intercept array of strings
.intercept(isStringArray, async (visitor, options, fields, { recurse }) => {
return Promise.all(fields.map((field) => recurse(visitor, options, field)));
})

View File

@ -6,7 +6,9 @@ import {
split,
isObject,
trim,
constant,
isNil,
identity,
cloneDeep,
join,
first,
@ -27,8 +29,6 @@ const isKeyword = (keyword: string) => {
const isStringArray = (value: unknown): value is string[] =>
isArray(value) && value.every(isString);
const isWildCardConstant = (value: unknown): value is '*' => value === '*';
const isObj = (value: unknown): value is Record<string, unknown> => isObject(value);
const populate = traverseFactory()
@ -40,24 +40,40 @@ const populate = traverseFactory()
return visitedPopulate.filter((item) => !isNil(item));
})
// Transform wildcard populate to an exhaustive list of attributes to populate.
.intercept(isWildCardConstant, (visitor, options, _data, { recurse }) => {
const attributes = options.schema?.attributes;
// for wildcard, generate custom utilities to modify the values
.parse(
(value): value is '*' => value === '*',
() => ({
/**
* Since value is '*', we don't need to transform it
*/
transform: identity,
// This should never happen, but adding the check in
// case this method is called with wrong parameters
if (!attributes) {
return '*';
}
/**
* '*' isn't a key/value structure, so regardless
* of the given key, it returns the data ('*')
*/
get: (_key, data) => data,
const parsedPopulate = Object.entries(attributes)
// Get the list of all attributes that can be populated
.filter(([, value]) => ['relation', 'component', 'dynamiczone', 'media'].includes(value.type))
// Only keep the attributes key
.reduce((acc, [key]) => ({ ...acc, [key]: true }), {});
/**
* '*' isn't a key/value structure, so regardless
* of the given `key`, use `value` as the new `data`
*/
set: (_key, value) => value,
/**
* '*' isn't a key/value structure, but we need to simulate at least one to enable
* the data traversal. We're using '' since it represents a falsy string value
*/
keys: constant(['']),
/**
* Removing '*' means setting it to undefined, regardless of the given key
*/
remove: constant(undefined),
})
)
return recurse(visitor, options, parsedPopulate);
})
// Parse string values
.parse(isString, () => {
const tokenize = split('.');

View File

@ -9,7 +9,7 @@ import * as visitors from './visitors';
import * as validators from './validators';
import traverseEntity from '../traverse-entity';
import { traverseQueryFilters, traverseQuerySort } from '../traverse';
import { traverseQueryFilters, traverseQuerySort, traverseQueryPopulate } from '../traverse';
import { Model, Data } from '../types';
@ -57,7 +57,7 @@ const createContentAPIValidators = () => {
.get('content-api.input')
.forEach((validator: Validator) => transforms.push(validator(schema)));
pipeAsync(...transforms)(data as Data);
await pipeAsync(...transforms)(data as Data);
};
const validateQuery = async (
@ -68,7 +68,7 @@ const createContentAPIValidators = () => {
if (!schema) {
throw new Error('Missing schema in validateQuery');
}
const { filters, sort, fields } = query;
const { filters, sort, fields, populate } = query;
if (filters) {
await validateFilters(filters, schema, { auth });
@ -82,7 +82,10 @@ const createContentAPIValidators = () => {
await validateFields(fields, schema);
}
// TODO: validate populate
// a wildcard is always valid; its conversion will be handled by the entity service and can be optimized with sanitizer
if (populate && populate !== '*') {
await validatePopulate(populate, schema);
}
};
const validateFilters: ValidateFunc = async (filters, schema: Model, { auth } = {}) => {
@ -100,7 +103,7 @@ const createContentAPIValidators = () => {
transforms.push(traverseQueryFilters(visitors.throwRestrictedRelations(auth), { schema }));
}
return pipeAsync(...transforms)(filters);
await pipeAsync(...transforms)(filters);
};
const validateSort: ValidateFunc = async (sort, schema: Model, { auth } = {}) => {
@ -113,16 +116,29 @@ const createContentAPIValidators = () => {
transforms.push(traverseQuerySort(visitors.throwRestrictedRelations(auth), { schema }));
}
return pipeAsync(...transforms)(sort);
await pipeAsync(...transforms)(sort);
};
const validateFields: ValidateFunc = (fields, schema: Model) => {
const validateFields: ValidateFunc = async (fields, schema: Model) => {
if (!schema) {
throw new Error('Missing schema in validateFields');
}
const transforms = [validators.defaultValidateFields(schema)];
return pipeAsync(...transforms)(fields);
await pipeAsync(...transforms)(fields);
};
const validatePopulate: ValidateFunc = async (populate, schema: Model, { auth } = {}) => {
if (!schema) {
throw new Error('Missing schema in sanitizePopulate');
}
const transforms = [validators.defaultValidatePopulate(schema)];
if (auth) {
transforms.push(traverseQueryPopulate(visitors.throwRestrictedRelations(auth), { schema }));
}
await pipeAsync(...transforms)(populate);
};
return {
@ -131,6 +147,7 @@ const createContentAPIValidators = () => {
filters: validateFilters,
sort: validateSort,
fields: validateFields,
populate: validatePopulate,
};
};

View File

@ -1,5 +1,8 @@
import { ValidationError } from '../errors';
export const throwInvalidParam = ({ key }: { key: string }) => {
throw new ValidationError(`Invalid parameter ${key}`);
export const throwInvalidParam = ({ key, path }: { key: string; path?: string | null }) => {
const msg =
path && path !== key ? `Invalid parameter ${key} at ${path}` : `Invalid parameter ${key}`;
throw new ValidationError(msg);
};

View File

@ -3,7 +3,12 @@ import { curry, isEmpty, isNil } from 'lodash/fp';
import { pipeAsync } from '../async';
import traverseEntity from '../traverse-entity';
import { isScalarAttribute } from '../content-types';
import { traverseQueryFilters, traverseQuerySort, traverseQueryFields } from '../traverse';
import {
traverseQueryFilters,
traverseQuerySort,
traverseQueryFields,
traverseQueryPopulate,
} from '../traverse';
import { throwPassword, throwPrivate, throwDynamicZones, throwMorphToRelations } from './visitors';
import { isOperator } from '../operators';
import { throwInvalidParam } from './utils';
@ -25,7 +30,7 @@ const defaultValidateFilters = curry((schema: Model, filters: unknown) => {
return pipeAsync(
// keys that are not attributes or valid operators
traverseQueryFilters(
({ key, attribute }) => {
({ key, attribute, path }) => {
// ID is not an attribute per se, so we need to make
// an extra check to ensure we're not removing it
if (key === 'id') {
@ -35,7 +40,7 @@ const defaultValidateFilters = curry((schema: Model, filters: unknown) => {
const isAttribute = !!attribute;
if (!isAttribute && !isOperator(key)) {
throwInvalidParam({ key });
throwInvalidParam({ key, path: path.attribute });
}
},
{ schema }
@ -60,7 +65,7 @@ const defaultValidateSort = curry((schema: Model, sort: unknown) => {
return pipeAsync(
// non attribute keys
traverseQuerySort(
({ key, attribute }) => {
({ key, attribute, path }) => {
// ID is not an attribute per se, so we need to make
// an extra check to ensure we're not removing it
if (key === 'id') {
@ -68,7 +73,7 @@ const defaultValidateSort = curry((schema: Model, sort: unknown) => {
}
if (!attribute) {
throwInvalidParam({ key });
throwInvalidParam({ key, path: path.attribute });
}
},
{ schema }
@ -83,7 +88,7 @@ const defaultValidateSort = curry((schema: Model, sort: unknown) => {
traverseQuerySort(throwPassword, { schema }),
// keys for empty non-scalar values
traverseQuerySort(
({ key, attribute, value }) => {
({ key, attribute, value, path }) => {
// ID is not an attribute per se, so we need to make
// an extra check to ensure we're not removing it
if (key === 'id') {
@ -91,7 +96,7 @@ const defaultValidateSort = curry((schema: Model, sort: unknown) => {
}
if (!isScalarAttribute(attribute) && isEmpty(value)) {
throwInvalidParam({ key });
throwInvalidParam({ key, path: path.attribute });
}
},
{ schema }
@ -106,7 +111,7 @@ const defaultValidateFields = curry((schema: Model, fields: unknown) => {
return pipeAsync(
// Only allow scalar attributes
traverseQueryFields(
({ key, attribute }) => {
({ key, attribute, path }) => {
// ID is not an attribute per se, so we need to make
// an extra check to ensure we're not removing it
if (key === 'id') {
@ -114,7 +119,7 @@ const defaultValidateFields = curry((schema: Model, fields: unknown) => {
}
if (isNil(attribute) || !isScalarAttribute(attribute)) {
throwInvalidParam({ key });
throwInvalidParam({ key, path: path.attribute });
}
},
{ schema }
@ -126,4 +131,44 @@ const defaultValidateFields = curry((schema: Model, fields: unknown) => {
)(fields);
});
export { throwPasswords, defaultValidateFilters, defaultValidateSort, defaultValidateFields };
const defaultValidatePopulate = curry((schema: Model, populate: unknown) => {
if (!schema) {
throw new Error('Missing schema in defaultValidatePopulate');
}
return pipeAsync(
traverseQueryPopulate(
async ({ key, value, schema, attribute }, { set }) => {
if (attribute) {
return;
}
if (key === 'sort') {
set(key, await defaultValidateSort(schema, value));
}
if (key === 'filters') {
set(key, await defaultValidateFilters(schema, value));
}
if (key === 'fields') {
set(key, await defaultValidateFields(schema, value));
}
if (key === 'populate') {
set(key, await defaultValidatePopulate(schema, value));
}
},
{ schema }
),
// Remove private fields
traverseQueryPopulate(throwPrivate, { schema })
)(populate);
});
export {
throwPasswords,
defaultValidateFilters,
defaultValidateSort,
defaultValidateFields,
defaultValidatePopulate,
};

View File

@ -69,7 +69,7 @@ export default (allowedFields: string[] | null = null): Visitor =>
}
// throw otherwise
throwInvalidParam({ key });
throwInvalidParam({ key, path });
};
/**

View File

@ -2,9 +2,9 @@ import { isDynamicZoneAttribute } from '../../content-types';
import { throwInvalidParam } from '../utils';
import type { Visitor } from '../../traverse/factory';
const visitor: Visitor = ({ key, attribute }) => {
const visitor: Visitor = ({ key, attribute, path }) => {
if (isDynamicZoneAttribute(attribute)) {
throwInvalidParam({ key });
throwInvalidParam({ key, path: path.attribute });
}
};

View File

@ -2,9 +2,9 @@ import { isMorphToRelationalAttribute } from '../../content-types';
import { throwInvalidParam } from '../utils';
import type { Visitor } from '../../traverse/factory';
const visitor: Visitor = ({ key, attribute }) => {
const visitor: Visitor = ({ key, attribute, path }) => {
if (isMorphToRelationalAttribute(attribute)) {
throwInvalidParam({ key });
throwInvalidParam({ key, path: path.attribute });
}
};

View File

@ -1,9 +1,9 @@
import { throwInvalidParam } from '../utils';
import type { Visitor } from '../../traverse/factory';
const visitor: Visitor = ({ key, attribute }) => {
const visitor: Visitor = ({ key, attribute, path }) => {
if (attribute?.type === 'password') {
throwInvalidParam({ key });
throwInvalidParam({ key, path: path.attribute });
}
};

View File

@ -2,7 +2,7 @@ import { isPrivateAttribute } from '../../content-types';
import { throwInvalidParam } from '../utils';
import type { Visitor } from '../../traverse/factory';
const visitor: Visitor = ({ schema, key, attribute }) => {
const visitor: Visitor = ({ schema, key, attribute, path }) => {
if (!attribute) {
return;
}
@ -10,7 +10,7 @@ const visitor: Visitor = ({ schema, key, attribute }) => {
const isPrivate = attribute.private === true || isPrivateAttribute(schema, key);
if (isPrivate) {
throwInvalidParam({ key });
throwInvalidParam({ key, path: path.attribute });
}
};

View File

@ -6,7 +6,7 @@ export default (restrictedFields: string[] | null = null): Visitor =>
({ key, path: { attribute: path } }) => {
// all fields
if (restrictedFields === null) {
throwInvalidParam({ key });
throwInvalidParam({ key, path });
}
// Throw on invalid formats
@ -18,7 +18,7 @@ export default (restrictedFields: string[] | null = null): Visitor =>
// if an exact match was found
if (restrictedFields.includes(path as string)) {
throwInvalidParam({ key });
throwInvalidParam({ key, path });
}
// nested matches
@ -26,6 +26,6 @@ export default (restrictedFields: string[] | null = null): Visitor =>
path?.toString().startsWith(`${allowedPath}.`)
);
if (isRestrictedNested) {
throwInvalidParam({ key });
throwInvalidParam({ key, path });
}
};

View File

@ -8,7 +8,7 @@ const { CREATED_BY_ATTRIBUTE, UPDATED_BY_ATTRIBUTE } = contentTypeUtils.constant
type MorphArray = Array<{ __type: string }>;
export default (auth: unknown): Visitor =>
async ({ data, key, attribute, schema }) => {
async ({ data, key, attribute, schema, path }) => {
if (!attribute) {
return;
}
@ -25,7 +25,7 @@ export default (auth: unknown): Visitor =>
const isAllowed = await hasAccessToSomeScopes(scopes, auth);
if (!isAllowed) {
throwInvalidParam({ key });
throwInvalidParam({ key, path: path.attribute });
}
}
};
@ -37,7 +37,7 @@ export default (auth: unknown): Visitor =>
// If the authenticated user don't have access to any of the scopes
if (!isAllowed) {
throwInvalidParam({ key });
throwInvalidParam({ key, path: path.attribute });
}
};