diff --git a/api/src/exceptions/failed-validation.ts b/api/src/exceptions/failed-validation.ts index 528d733141..b0c92cb5b9 100644 --- a/api/src/exceptions/failed-validation.ts +++ b/api/src/exceptions/failed-validation.ts @@ -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); } } diff --git a/api/src/services/authorization.ts b/api/src/services/authorization.ts index 7a9b74f711..2ed92157f0 100644 --- a/api/src/services/authorization.ts +++ b/api/src/services/authorization.ts @@ -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[] | Partial ): Promise[] | Partial> { + const validationErrors: FailedValidationException[] = []; + let payloads = Array.isArray(payload) ? payload : [payload]; - const permission = await this.knex - .select('*') - .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('*') + .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, payloads: Partial>[]): 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) => 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) => 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 { diff --git a/api/src/services/items.ts b/api/src/services/items.ts index 2ee884083b..eb40bd1a7a 100644 --- a/api/src/services/items.ts +++ b/api/src/services/items.ts @@ -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, diff --git a/api/src/utils/generate-joi.ts b/api/src/utils/generate-joi.ts index d0928b403f..2db7b13bae 100644 --- a/api/src/utils/generate-joi.ts +++ b/api/src/utils/generate-joi.ts @@ -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 = {}; + 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(); + } +} diff --git a/api/src/utils/test.ts b/api/src/utils/test.ts new file mode 100644 index 0000000000..9825ae5e1e --- /dev/null +++ b/api/src/utils/test.ts @@ -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)); diff --git a/app/src/components/v-form/form-field-label.vue b/app/src/components/v-form/form-field-label.vue index 535fbcaac6..1066b6d8bb 100644 --- a/app/src/components/v-form/form-field-label.vue +++ b/app/src/components/v-form/form-field-label.vue @@ -8,7 +8,7 @@ /> {{ field.name }} - + diff --git a/app/src/lang/en-US/index.json b/app/src/lang/en-US/index.json index 78661c61fe..04b7d44467 100644 --- a/app/src/lang/en-US/index.json +++ b/app/src/lang/en-US/index.json @@ -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",