Merge pull request #445 from directus/required

Surface required in app
This commit is contained in:
Rijk van Zanten
2020-09-29 18:01:35 -04:00
committed by GitHub
7 changed files with 204 additions and 98 deletions

View File

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

View File

@@ -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 {

View File

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

View File

@@ -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
View 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));

View File

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

View File

@@ -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",