mirror of
https://github.com/directus/directus.git
synced 2026-01-30 16:58:36 -05:00
@@ -4,7 +4,7 @@ import { FilterOperator } from '../types';
|
||||
|
||||
type FailedValidationExtensions = {
|
||||
field: string;
|
||||
type: FilterOperator;
|
||||
type: FilterOperator | 'required';
|
||||
valid?: number | string | (number | string)[];
|
||||
invalid?: number | string | (number | string)[];
|
||||
substring?: string;
|
||||
@@ -92,6 +92,11 @@ export class FailedValidationException extends BaseException {
|
||||
extensions.substring = error.context?.substring;
|
||||
}
|
||||
|
||||
// required
|
||||
if (joiType.endsWith('required')) {
|
||||
extensions.type = 'required';
|
||||
}
|
||||
|
||||
super(error.message, 400, 'FAILED_VALIDATION', extensions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Item,
|
||||
PrimaryKey,
|
||||
} from '../types';
|
||||
import SchemaInspector from 'knex-schema-inspector';
|
||||
import Knex from 'knex';
|
||||
import { ForbiddenException, FailedValidationException } from '../exceptions';
|
||||
import { uniq, merge } from 'lodash';
|
||||
@@ -190,29 +191,39 @@ export class AuthorizationService {
|
||||
collection: string,
|
||||
payload: Partial<Item>[] | Partial<Item>
|
||||
): Promise<Partial<Item>[] | Partial<Item>> {
|
||||
const validationErrors: FailedValidationException[] = [];
|
||||
|
||||
let payloads = Array.isArray(payload) ? payload : [payload];
|
||||
|
||||
const permission = await this.knex
|
||||
.select<Permission>('*')
|
||||
.from('directus_permissions')
|
||||
.where({ action, collection, role: this.accountability?.role || null })
|
||||
.first();
|
||||
let permission: Permission | undefined;
|
||||
|
||||
if (!permission) throw new ForbiddenException();
|
||||
if (this.accountability?.admin === true) {
|
||||
permission = { id: 0, role: this.accountability?.role, collection, action, permissions: {}, validation: {}, limit: null, fields: '*', presets: {}, }
|
||||
} else {
|
||||
permission = await this.knex
|
||||
.select<Permission>('*')
|
||||
.from('directus_permissions')
|
||||
.where({ action, collection, role: this.accountability?.role || null })
|
||||
.first();
|
||||
|
||||
const allowedFields = permission.fields?.split(',') || [];
|
||||
// Check if you have permission to access the fields you're trying to acces
|
||||
|
||||
if (allowedFields.includes('*') === false) {
|
||||
for (const payload of payloads) {
|
||||
const keysInData = Object.keys(payload);
|
||||
const invalidKeys = keysInData.filter(
|
||||
(fieldKey) => allowedFields.includes(fieldKey) === false
|
||||
);
|
||||
if (!permission) throw new ForbiddenException();
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
throw new ForbiddenException(
|
||||
`You're not allowed to ${action} field "${invalidKeys[0]}" in collection "${collection}".`
|
||||
const allowedFields = permission.fields?.split(',') || [];
|
||||
|
||||
if (allowedFields.includes('*') === false) {
|
||||
for (const payload of payloads) {
|
||||
const keysInData = Object.keys(payload);
|
||||
const invalidKeys = keysInData.filter(
|
||||
(fieldKey) => allowedFields.includes(fieldKey) === false
|
||||
);
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
throw new ForbiddenException(
|
||||
`You're not allowed to ${action} field "${invalidKeys[0]}" in collection "${collection}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,16 +232,37 @@ export class AuthorizationService {
|
||||
|
||||
payloads = payloads.map((payload) => merge({}, preset, payload));
|
||||
|
||||
const schema = generateJoi(permission.validation);
|
||||
const schemaInspector = SchemaInspector(this.knex);
|
||||
const columns = await schemaInspector.columnInfo(collection);
|
||||
const requiredColumns = columns.filter((column) => column.is_nullable === false && column.has_auto_increment === false && column.default_value === null);
|
||||
|
||||
for (const payload of payloads) {
|
||||
const { error } = schema.validate(payload, { abortEarly: false });
|
||||
if (requiredColumns.length > 0) {
|
||||
permission.validation = {
|
||||
_and: [
|
||||
permission.validation,
|
||||
{}
|
||||
]
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error.details.map((details) => new FailedValidationException(details));
|
||||
if (action === 'create') {
|
||||
for (const { name } of requiredColumns) {
|
||||
permission.validation._and[1][name] = {
|
||||
_required: true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const { name } of requiredColumns) {
|
||||
permission.validation._and[1][name] = {
|
||||
_nnull: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validationErrors.push(...this.validateJoi(permission.validation, payloads));
|
||||
|
||||
if (validationErrors.length > 0) throw validationErrors;
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
return payloads;
|
||||
} else {
|
||||
@@ -238,11 +270,49 @@ export class AuthorizationService {
|
||||
}
|
||||
}
|
||||
|
||||
validateJoi(validation: Record<string, any>, payloads: Partial<Record<string, any>>[]): FailedValidationException[] {
|
||||
const errors: FailedValidationException[] = [];
|
||||
|
||||
/**
|
||||
* Note there can only be a single _and / _or per level
|
||||
*/
|
||||
|
||||
if (Object.keys(validation)[0] === '_and') {
|
||||
const subValidation = Object.values(validation)[0];
|
||||
const nestedErrors = subValidation.map((subObj: Record<string, any>) => this.validateJoi(subObj, payloads)).flat().filter((err?: FailedValidationException) => err);
|
||||
errors.push(...nestedErrors);
|
||||
}
|
||||
|
||||
if (Object.keys(validation)[0] === '_or') {
|
||||
const subValidation = Object.values(validation)[0];
|
||||
const nestedErrors = subValidation.map((subObj: Record<string, any>) => this.validateJoi(subObj, payloads)).flat();
|
||||
const allErrored = nestedErrors.every((err?: FailedValidationException) => err);
|
||||
|
||||
if (allErrored) {
|
||||
errors.push(...nestedErrors);
|
||||
}
|
||||
}
|
||||
|
||||
const schema = generateJoi(validation);
|
||||
|
||||
for (const payload of payloads) {
|
||||
const { error } = schema.validate(payload, { abortEarly: false });
|
||||
|
||||
if (error) {
|
||||
errors.push(...error.details.map((details) => new FailedValidationException(details)));
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
async checkAccess(
|
||||
action: PermissionsAction,
|
||||
collection: string,
|
||||
pk: PrimaryKey | PrimaryKey[]
|
||||
) {
|
||||
if (this.accountability?.admin === true) return;
|
||||
|
||||
const itemsService = new ItemsService(collection, { accountability: this.accountability });
|
||||
|
||||
try {
|
||||
|
||||
@@ -71,7 +71,7 @@ export class ItemsService implements AbstractService {
|
||||
payloads = customProcessed[customProcessed.length - 1];
|
||||
}
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
if (this.accountability) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
knex: trx,
|
||||
@@ -284,11 +284,13 @@ export class ItemsService implements AbstractService {
|
||||
payload = customProcessed[customProcessed.length - 1];
|
||||
}
|
||||
|
||||
if (this.accountability && this.accountability.admin !== true) {
|
||||
if (this.accountability) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
});
|
||||
|
||||
await authorizationService.checkAccess('update', this.collection, keys);
|
||||
|
||||
payload = await authorizationService.validatePayload(
|
||||
'update',
|
||||
this.collection,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Filter } from '../types';
|
||||
import BaseJoi, { AnySchema } from 'joi';
|
||||
import BaseJoi, { AlternativesSchema, ObjectSchema, AnySchema } from 'joi';
|
||||
|
||||
const Joi: typeof BaseJoi = BaseJoi.extend({
|
||||
type: 'string',
|
||||
@@ -52,86 +52,97 @@ const Joi: typeof BaseJoi = BaseJoi.extend({
|
||||
},
|
||||
});
|
||||
|
||||
export default function generateJoi(filter: Filter | null) {
|
||||
export default function generateJoi(filter: Filter | null): AnySchema {
|
||||
filter = filter || {};
|
||||
|
||||
const schema: Record<string, AnySchema> = {};
|
||||
if (Object.keys(filter).length === 0) return Joi.any();
|
||||
|
||||
let schema: any;
|
||||
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
const isField = key.startsWith('_') === false;
|
||||
if (key.startsWith('_') === false) {
|
||||
if (!schema) schema = {};
|
||||
|
||||
if (isField) {
|
||||
const operator = Object.keys(value)[0];
|
||||
const val = Object.keys(value)[1];
|
||||
|
||||
if (operator === '_eq') {
|
||||
schema[key] = Joi.any().equal(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_neq') {
|
||||
schema[key] = Joi.any().not(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_contains') {
|
||||
// @ts-ignore
|
||||
schema[key] = Joi.string().contains(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_ncontains') {
|
||||
// @ts-ignore
|
||||
schema[key] = Joi.string().ncontains(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_in') {
|
||||
schema[key] = Joi.any().equal(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
|
||||
if (operator === '_nin') {
|
||||
schema[key] = Joi.any().not(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
|
||||
if (operator === '_gt') {
|
||||
schema[key] = Joi.number().greater(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_gte') {
|
||||
schema[key] = Joi.number().min(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lt') {
|
||||
schema[key] = Joi.number().less(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lte') {
|
||||
schema[key] = Joi.number().max(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_null') {
|
||||
schema[key] = Joi.any().valid(null);
|
||||
}
|
||||
|
||||
if (operator === '_nnull') {
|
||||
schema[key] = Joi.any().invalid(null);
|
||||
}
|
||||
|
||||
if (operator === '_empty') {
|
||||
schema[key] = Joi.any().valid('');
|
||||
}
|
||||
|
||||
if (operator === '_nempty') {
|
||||
schema[key] = Joi.any().invalid('');
|
||||
}
|
||||
|
||||
if (operator === '_between') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
schema[key] = Joi.number().greater(values[0]).less(values[1]);
|
||||
}
|
||||
|
||||
if (operator === '_nbetween') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
schema[key] = Joi.number().less(values[0]).greater(values[1]);
|
||||
}
|
||||
schema[key] = getJoi(operator, val);
|
||||
}
|
||||
}
|
||||
|
||||
return Joi.object(schema).unknown();
|
||||
}
|
||||
|
||||
function getJoi(operator: string, value: any) {
|
||||
if (operator === '_eq') {
|
||||
return Joi.any().equal(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_neq') {
|
||||
return Joi.any().not(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_contains') {
|
||||
// @ts-ignore
|
||||
return Joi.string().contains(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_ncontains') {
|
||||
// @ts-ignore
|
||||
return Joi.string().ncontains(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_in') {
|
||||
return Joi.any().equal(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
|
||||
if (operator === '_nin') {
|
||||
return Joi.any().not(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
|
||||
if (operator === '_gt') {
|
||||
return Joi.number().greater(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_gte') {
|
||||
return Joi.number().min(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lt') {
|
||||
return Joi.number().less(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lte') {
|
||||
return Joi.number().max(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_null') {
|
||||
return Joi.any().valid(null);
|
||||
}
|
||||
|
||||
if (operator === '_nnull') {
|
||||
return Joi.any().invalid(null);
|
||||
}
|
||||
|
||||
if (operator === '_empty') {
|
||||
return Joi.any().valid('');
|
||||
}
|
||||
|
||||
if (operator === '_nempty') {
|
||||
return Joi.any().invalid('');
|
||||
}
|
||||
|
||||
if (operator === '_between') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
return Joi.number().greater(values[0]).less(values[1]);
|
||||
}
|
||||
|
||||
if (operator === '_nbetween') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
return Joi.number().less(values[0]).greater(values[1]);
|
||||
}
|
||||
|
||||
if (operator === '_required') {
|
||||
return Joi.invalid(null).required();
|
||||
}
|
||||
}
|
||||
|
||||
17
api/src/utils/test.ts
Normal file
17
api/src/utils/test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import Joi from 'joi';
|
||||
|
||||
const schema = Joi.alternatives().try(
|
||||
Joi.object({
|
||||
name: Joi.string().required(),
|
||||
age: Joi.number()
|
||||
}),
|
||||
Joi.string(),
|
||||
).match('all');
|
||||
|
||||
const value = {
|
||||
age: 25
|
||||
};
|
||||
|
||||
const { error } = schema.validate(value);
|
||||
|
||||
console.log(JSON.stringify(error, null, 2));
|
||||
@@ -8,7 +8,7 @@
|
||||
/>
|
||||
<span @click="toggle">
|
||||
{{ field.name }}
|
||||
<v-icon class="required" sup name="star" v-if="field.required" />
|
||||
<v-icon class="required" sup name="star" v-if="field.schema && field.schema.is_nullable === false" />
|
||||
<v-icon v-if="!disabled" class="ctx-arrow" :class="{ active }" name="arrow_drop_down" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -81,7 +81,8 @@
|
||||
"empty": "Value has to be empty",
|
||||
"nempty": "Value can't be empty",
|
||||
"null": "Value has to be null",
|
||||
"nnull": "Value can't be null"
|
||||
"nnull": "Value can't be null",
|
||||
"required": "Value is required"
|
||||
},
|
||||
|
||||
"all_access": "All Access",
|
||||
|
||||
Reference in New Issue
Block a user