From 54a2b3b74d3b839bdb00ddc7e30364238ffc7fb3 Mon Sep 17 00:00:00 2001 From: rijkvanzanten Date: Tue, 14 Jul 2020 16:53:32 -0400 Subject: [PATCH] Add fields permissions --- src/database/run-ast.ts | 107 ++++++++++++++------ src/routes/items.ts | 2 + src/services/permissions.ts | 170 ++++++++++++++++++++------------ src/utils/get-ast-from-query.ts | 107 ++++++++++---------- tsconfig.json | 2 +- 5 files changed, 238 insertions(+), 150 deletions(-) diff --git a/src/database/run-ast.ts b/src/database/run-ast.ts index cc6cdf8d13..7724b10f5c 100644 --- a/src/database/run-ast.ts +++ b/src/database/run-ast.ts @@ -1,16 +1,24 @@ import { AST, NestedCollectionAST } from '../types/ast'; -import { uniq } from 'lodash'; +import { uniq, pick } from 'lodash'; import database, { schemaInspector } from './index'; import { Filter, Query } from '../types'; import { QueryBuilder } from 'knex'; export default async function runAST(ast: AST, query = ast.query) { const toplevelFields: string[] = []; + const tempFields: string[] = []; const nestedCollections: NestedCollectionAST[] = []; + const primaryKeyField = await schemaInspector.primary(ast.name); + const columnsInCollection = (await schemaInspector.columns(ast.name)).map( + ({ column }) => column + ); for (const child of ast.children) { if (child.type === 'field') { - toplevelFields.push(child.name); + if (columnsInCollection.includes(child.name)) { + toplevelFields.push(child.name); + } + continue; } @@ -24,7 +32,12 @@ export default async function runAST(ast: AST, query = ast.query) { nestedCollections.push(child); } - let dbQuery = database.select(toplevelFields).from(ast.name); + /** Always fetch primary key in case there's a nested relation that needs it */ + if (toplevelFields.includes(primaryKeyField) === false) { + tempFields.push(primaryKeyField); + } + + let dbQuery = database.select([...toplevelFields, ...tempFields]).from(ast.name); if (query.filter) { applyFilter(dbQuery, query.filter); @@ -74,8 +87,18 @@ export default async function runAST(ast: AST, query = ast.query) { const m2o = isM2O(batch); let batchQuery: Query = {}; + let tempField: string = null; if (m2o) { + // Make sure we always fetch the nested items primary key field to ensure we have the key to match the item by + const toplevelFields = batch.children + .filter(({ type }) => type === 'field') + .map(({ name }) => name); + if (toplevelFields.includes(batch.relation.primary_one) === false) { + tempField = batch.relation.primary_one; + batch.children.push({ type: 'field', name: batch.relation.primary_one }); + } + batchQuery = { ...batch.query, filter: { @@ -88,6 +111,17 @@ export default async function runAST(ast: AST, query = ast.query) { }, }; } else { + // o2m + // Make sure we always fetch the related m2o field to ensure we have the foreign key to + // match the items by + const toplevelFields = batch.children + .filter(({ type }) => type === 'field') + .map(({ name }) => name); + if (toplevelFields.includes(batch.relation.field_many) === false) { + tempField = batch.relation.field_many; + batch.children.push({ type: 'field', name: batch.relation.field_many }); + } + batchQuery = { ...batch.query, filter: { @@ -103,34 +137,53 @@ export default async function runAST(ast: AST, query = ast.query) { results = results.map((record) => { if (m2o) { + const nestedResult = + nestedResults.find((nestedRecord) => { + return nestedRecord[batch.relation.primary_one] === record[batch.fieldKey]; + }) || null; + + if (tempField && nestedResult) { + delete nestedResult[tempField]; + } + return { ...record, - [batch.fieldKey]: - nestedResults.find((nestedRecord) => { - return ( - nestedRecord[batch.relation.primary_one] === record[batch.fieldKey] - ); - }) || null, + [batch.fieldKey]: nestedResult, }; } - return { + // o2m + const newRecord = { ...record, - [batch.fieldKey]: nestedResults.filter((nestedRecord) => { - /** - * @todo - * pull the name ID from somewhere real - */ - return ( - nestedRecord[batch.relation.field_many] === record.id || - nestedRecord[batch.relation.field_many]?.id === record.id - ); - }), + [batch.fieldKey]: nestedResults + .filter((nestedRecord) => { + /** + * @todo + * pull the name ID from somewhere real + */ + return ( + nestedRecord[batch.relation.field_many] === record.id || + nestedRecord[batch.relation.field_many]?.id === record.id + ); + }) + .map((nestedRecord) => { + if (tempField) { + delete nestedRecord[tempField]; + } + + return nestedRecord; + }), }; + + return newRecord; }); } - return results; + const nestedCollectionKeys = nestedCollections.map(({ fieldKey }) => fieldKey); + + return results.map((result) => + pick(result, uniq([...nestedCollectionKeys, ...toplevelFields])) + ); } function isM2O(child: NestedCollectionAST) { @@ -143,31 +196,29 @@ function applyFilter(dbQuery: QueryBuilder, filter: Filter) { for (const [key, value] of Object.entries(filter)) { if (key.startsWith('_') === false) { let operator = Object.keys(value)[0]; - operator = operator.slice(1); - operator = operator.toLowerCase(); const compareValue = Object.values(value)[0]; - if (operator === 'eq') { + if (operator === '_eq') { dbQuery.where({ [key]: compareValue }); } - if (operator === 'neq') { + if (operator === '_neq') { dbQuery.whereNot({ [key]: compareValue }); } - if (operator === 'in') { + if (operator === '_in') { let value = compareValue; if (typeof value === 'string') value = value.split(','); dbQuery.whereIn(key, value as string[]); } - if (operator === 'null') { + if (operator === '_null') { dbQuery.whereNull(key); } - if (operator === 'nnull') { + if (operator === '_nnull') { dbQuery.whereNotNull(key); } } diff --git a/src/routes/items.ts b/src/routes/items.ts index 3196845f3f..acb2fd8f15 100644 --- a/src/routes/items.ts +++ b/src/routes/items.ts @@ -38,6 +38,8 @@ router.get( asyncHandler(async (req, res) => { let ast = await getASTFromQuery(req.role, req.collection, req.sanitizedQuery); + console.log(JSON.stringify(ast, null, 2)); + ast = await PermissionsService.processAST(req.role, ast); const [records, meta] = await Promise.all([ diff --git a/src/services/permissions.ts b/src/services/permissions.ts index ca27516fac..1b6c348a55 100644 --- a/src/services/permissions.ts +++ b/src/services/permissions.ts @@ -6,10 +6,12 @@ import { Operation, Query, Permission, + Relation, } from '../types'; import * as ItemsService from './items'; import database from '../database'; import { ForbiddenException } from '../exceptions'; +import { uniq } from 'lodash'; export const createPermission = async ( data: Record, @@ -69,6 +71,7 @@ export const authorize = async (operation: Operation, collection: string, role?: export const processAST = async (role: string | null, ast: AST): Promise => { const collectionsRequested = getCollectionsFromAST(ast); + const permissionsForCollections = await database .select('*') .from('directus_permissions') @@ -78,10 +81,30 @@ export const processAST = async (role: string | null, ast: AST): Promise => ) .andWhere('role', role); - validateCollections(); + // If the permissions don't match the collections, you don't have permission to read all of them + const uniqueCollectionsRequestedCount = uniq( + collectionsRequested.map(({ collection }) => collection) + ).length; - // Convert the requested `*` fields to the actual allowed fields, so we don't attempt to fetch data you're not supposed to see - ast = convertWildcards(ast); + if (uniqueCollectionsRequestedCount !== permissionsForCollections.length) { + // Find the first collection that doesn't have permissions configured + const { collection, field } = collectionsRequested.find( + ({ collection }) => + permissionsForCollections.find( + (permission) => permission.collection === collection + ) === undefined + ); + + if (field) { + throw new ForbiddenException( + `You don't have permission to access the "${field}" field.` + ); + } else { + throw new ForbiddenException( + `You don't have permission to access the "${collection}" collection.` + ); + } + } validateFields(ast); @@ -111,68 +134,6 @@ export const processAST = async (role: string | null, ast: AST): Promise => return collections; } - function validateCollections() { - // If the permissions don't match the collections, you don't have permission to read all of them - if (collectionsRequested.length !== permissionsForCollections.length) { - // Find the first collection that doesn't have permissions configured - const { collection, field } = collectionsRequested.find( - ({ collection }) => - permissionsForCollections.find( - (permission) => permission.collection === collection - ) === undefined - ); - - if (field) { - throw new ForbiddenException( - `You don't have permission to access the "${field}" field.` - ); - } else { - throw new ForbiddenException( - `You don't have permission to access the "${collection}" collection.` - ); - } - } - } - - /** - * Replace all requested wildcard `*` fields with the fields you're allowed to read - */ - function convertWildcards(ast: AST | NestedCollectionAST) { - if (ast.type === 'collection') { - const permission = permissionsForCollections.find( - (permission) => permission.collection === ast.name - ); - - const wildcardIndex = ast.children.findIndex((nestedAST) => { - return nestedAST.type === 'field' && nestedAST.name === '*'; - }); - - // Replace wildcard with array of fields you're allowed to read - if (wildcardIndex !== -1) { - const allowedFields = permission?.fields; - - if (allowedFields !== '*') { - const fields: FieldAST[] = allowedFields - .split(',') - .map((fieldKey) => ({ type: 'field', name: fieldKey })); - ast.children.splice(wildcardIndex, 1, ...fields); - } - } - - ast.children = ast.children - .map((childAST) => { - if (childAST.type === 'collection') { - return convertWildcards(childAST) as NestedCollectionAST | FieldAST; - } - - return childAST; - }) - .filter((c) => c); - } - - return ast; - } - function validateFields(ast: AST | NestedCollectionAST) { if (ast.type === 'collection') { const collection = ast.name; @@ -197,3 +158,82 @@ export const processAST = async (role: string | null, ast: AST): Promise => } } }; + +/* +// Swap *.* case for *,.*,.* +for (let index = 0; index < fields.length; index++) { + const fieldKey = fields[index]; + + if (fieldKey.includes('.') === false) continue; + + const parts = fieldKey.split('.'); + + if (parts[0] === '*') { + const availableFields = await FieldsService.fieldsInCollection(parentCollection); + const allowedCollections = permissions.map(({ collection }) => collection); + + const relationalFields = availableFields.filter((field) => { + const relation = getRelation(parentCollection, field); + if (!relation) return false; + + return ( + allowedCollections.includes(relation.collection_one) && + allowedCollections.includes(relation.collection_many) + ); + }); + + const nestedFieldKeys = relationalFields.map( + (relationalField) => `${relationalField}.${parts.slice(1).join('.')}` + ); + + fields.splice(index, 1, ...nestedFieldKeys); + + fields.push('*'); + } +} + + +function convertWildcards(ast: AST | NestedCollectionAST) { + if (ast.type === 'collection') { + const permission = permissionsForCollections.find( + (permission) => permission.collection === ast.name + ); + + const wildcardIndex = ast.children.findIndex((nestedAST) => { + return nestedAST.type === 'field' && nestedAST.name === '*'; + }); + + // Replace wildcard with array of fields you're allowed to read + if (wildcardIndex !== -1) { + const allowedFields = permission?.fields; + + if (allowedFields !== '*') { + const currentFieldKeys = ast.children.map((field) => field.type === 'field' ? field.name : field.fieldKey); + console.log(currentFieldKeys); + const fields: FieldAST[] = allowedFields + .split(',') + // Make sure we don't include nested collections as columns + .filter((fieldKey) => { + console.log(currentFieldKeys, fieldKey, currentFieldKeys.includes(fieldKey)); + return currentFieldKeys.includes(fieldKey) === false; + }) + .map((fieldKey) => ({ type: 'field', name: fieldKey })); + + ast.children.splice(wildcardIndex, 1, ...fields); + } + } + + ast.children = ast.children + .map((childAST) => { + if (childAST.type === 'collection') { + return convertWildcards(childAST) as NestedCollectionAST | FieldAST; + } + + return childAST; + }) + .filter((c) => c); + } + + return ast; +} +*/ diff --git a/src/utils/get-ast-from-query.ts b/src/utils/get-ast-from-query.ts index e095ed4f7f..4f2a856f4e 100644 --- a/src/utils/get-ast-from-query.ts +++ b/src/utils/get-ast-from-query.ts @@ -2,10 +2,9 @@ * Generate an AST based on a given collection and query */ -import { Query } from '../types/query'; import { Relation } from '../types/relation'; -import { AST, NestedCollectionAST, FieldAST } from '../types/ast'; -import database from '../database'; +import { AST, NestedCollectionAST, FieldAST, Query } from '../types'; +import database, { schemaInspector } from '../database'; import * as FieldsService from '../services/fields'; export default async function getASTFromQuery( @@ -13,6 +12,16 @@ export default async function getASTFromQuery( collection: string, query: Query ): Promise { + /** + * we might not need al this info at all times, but it's easier to fetch it all once, than trying to fetch it for every + * requested field. @todo look into utilizing graphql/dataloader for this purpose + */ + const permissions = await database + .select<{ collection: string; fields: string }[]>('collection', 'fields') + .from('directus_permissions') + .where({ role, operation: 'read' }); + const relations = await database.select('*').from('directus_relations'); + const ast: AST = { type: 'collection', name: collection, @@ -20,76 +29,62 @@ export default async function getASTFromQuery( children: [], }; - const fields = query.fields || ['*']; + const fields = convertWildcards(collection, query.fields || ['*']); // Prevent fields from showing up in the query object delete query.fields; - // If no relational fields are requested, we can stop early - const hasRelations = fields.some((field) => field.includes('.')); - - if (hasRelations === false) { - fields.forEach((field) => { - ast.children.push({ - type: 'field', - name: field, - }); - }); - - return ast; - } - - // Even though we probably don't need all relations in this request, it's faster to fetch all of them up front - // and search through the relations in memory than to attempt to read each relation as a single SQL query - // @TODO look into using graphql/dataloader for this purpose - const relations = await database.select('*').from('directus_relations'); - - // All collections the current user is allowed to see. This is used to transform the wildcard requests into fields the - // user is actually allowed to read - const allowedCollections = ( - await database.select('collection').from('directus_permissions').where({ role }) - ).map(({ collection }) => collection); - - ast.children = await parseFields(collection, fields); + ast.children = parseFields(collection, fields); return ast; - async function parseFields(parentCollection: string, fields: string[]) { - const children: (NestedCollectionAST | FieldAST)[] = []; + function convertWildcards(parentCollection: string, fields: string[]) { + const allowedFields = permissions + .find((permission) => parentCollection === permission.collection) + ?.fields?.split(','); - const relationalStructure: Record = {}; - - // Swap *.* case for *,.*,.* for (let index = 0; index < fields.length; index++) { const fieldKey = fields[index]; - if (fieldKey.includes('.') === false) continue; + if (fieldKey.includes('*') === false) continue; - const parts = fieldKey.split('.'); + if (fieldKey === '*') { + fields.splice(index, 1, ...allowedFields); + } - if (parts[0] === '*') { - const availableFields = await FieldsService.fieldsInCollection(parentCollection); - - const relationalFields = availableFields.filter((field) => { - const relation = getRelation(parentCollection, field); - if (!relation) return false; - - return ( - allowedCollections.includes(relation.collection_one) && - allowedCollections.includes(relation.collection_many) - ); - }); - - const nestedFieldKeys = relationalFields.map( - (relationalField) => `${relationalField}.${parts.slice(1).join('.')}` + // Swap *.* case for *,.*,.* + if (fieldKey.includes('.') && fieldKey.split('.')[0] === '*') { + const parts = fieldKey.split('.'); + const relationalFields = allowedFields.filter( + (fieldKey) => !!getRelation(parentCollection, fieldKey) + ); + const nonRelationalFields = allowedFields.filter( + (fieldKey) => relationalFields.includes(fieldKey) === false ); - fields.splice(index, 1, ...nestedFieldKeys); - - fields.push('*'); + fields.splice( + index, + 1, + ...[ + ...relationalFields.map((relationalField) => { + return `${relationalField}.${parts.slice(1).join('.')}`; + }), + ...nonRelationalFields, + ] + ); } } + return fields; + } + + function parseFields(parentCollection: string, fields: string[]) { + fields = convertWildcards(parentCollection, fields); + + const children: (NestedCollectionAST | FieldAST)[] = []; + + const relationalStructure: Record = {}; + for (const field of fields) { if (field.includes('.') === false) { children.push({ type: 'field', name: field }); @@ -115,7 +110,7 @@ export default async function getASTFromQuery( parentKey: 'id' /** @todo this needs to come from somewhere real */, relation: getRelation(parentCollection, relationalField), query: {} /** @todo inject nested query here */, - children: await parseFields(relatedCollection, nestedFields), + children: parseFields(relatedCollection, nestedFields), }; children.push(child); diff --git a/tsconfig.json b/tsconfig.json index 29c1c7ba4e..9ab7799317 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,6 @@ "rootDir": "src" }, "lib": [ - "es2015" + "es2019" ], }