mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Add fields permissions
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
"rootDir": "src"
|
||||
},
|
||||
"lib": [
|
||||
"es2015"
|
||||
"es2019"
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user