Add fields permissions

This commit is contained in:
rijkvanzanten
2020-07-14 16:53:32 -04:00
parent 0deaa77f0b
commit 54a2b3b74d
5 changed files with 238 additions and 150 deletions

View File

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

View File

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

View File

@@ -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<string, any>,
@@ -69,6 +71,7 @@ export const authorize = async (operation: Operation, collection: string, role?:
export const processAST = async (role: string | null, ast: AST): Promise<AST> => {
const collectionsRequested = getCollectionsFromAST(ast);
const permissionsForCollections = await database
.select<Permission[]>('*')
.from('directus_permissions')
@@ -78,10 +81,30 @@ export const processAST = async (role: string | null, ast: AST): Promise<AST> =>
)
.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<AST> =>
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<AST> =>
}
}
};
/*
// Swap *.* case for *,<relational-field>.*,<another-relational>.*
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;
}
*/

View File

@@ -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<AST> {
/**
* 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<Relation[]>('*').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<Relation[]>('*').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<string, string[]> = {};
// Swap *.* case for *,<relational-field>.*,<another-relational>.*
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 *,<relational-field>.*,<another-relational>.*
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<string, string[]> = {};
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);

View File

@@ -9,6 +9,6 @@
"rootDir": "src"
},
"lib": [
"es2015"
"es2019"
],
}