Rework collections flow to include permissions/accountability

This commit is contained in:
rijkvanzanten
2020-07-31 16:09:35 -04:00
parent 3895e36710
commit eefa74c821
2 changed files with 239 additions and 56 deletions

View File

@@ -2,8 +2,6 @@ import { Router } from 'express';
import asyncHandler from 'express-async-handler';
import sanitizeQuery from '../middleware/sanitize-query';
import CollectionsService from '../services/collections';
import { schemaInspector } from '../database';
import { CollectionNotFoundException } from '../exceptions';
import useCollection from '../middleware/use-collection';
const router = Router();
@@ -24,10 +22,9 @@ router.post(
router.get(
'/',
useCollection('directus_collections'),
sanitizeQuery,
asyncHandler(async (req, res) => {
const collectionsService = new CollectionsService({ accountability: req.accountability });
const collections = await collectionsService.readByQuery(req.sanitizedQuery);
const collections = await collectionsService.readByQuery();
res.json({ data: collections || null });
})
@@ -38,17 +35,25 @@ router.get(
useCollection('directus_collections'),
sanitizeQuery,
asyncHandler(async (req, res) => {
/** @todo move this validation to CollectionsService methods */
const exists = await schemaInspector.hasTable(req.params.collection);
if (exists === false) throw new CollectionNotFoundException(req.params.collection);
const collectionsService = new CollectionsService({ accountability: req.accountability });
const collectionKey = req.params.collection.includes(',')
? req.params.collection.split(',')
: req.params.collection;
const collection = await collectionsService.readByKey(collectionKey as any);
res.json({ data: collection || null });
})
);
const collection = await collectionsService.readByKey(
req.params.collection,
req.sanitizedQuery
);
router.patch(
'/:collection',
useCollection('directus_collections'),
asyncHandler(async (req, res) => {
const collectionsService = new CollectionsService({ accountability: req.accountability });
const collectionKey = req.params.collection.includes(',')
? req.params.collection.split(',')
: req.params.collection;
await collectionsService.update(req.body, collectionKey as any);
const collection = await collectionsService.readByKey(collectionKey as any);
res.json({ data: collection || null });
})
);
@@ -57,13 +62,11 @@ router.delete(
'/:collection',
useCollection('directus_collections'),
asyncHandler(async (req, res) => {
/** @todo move this validation to CollectionsService methods */
if ((await schemaInspector.hasTable(req.params.collection)) === false) {
throw new CollectionNotFoundException(req.params.collection);
}
const collectionsService = new CollectionsService({ accountability: req.accountability });
await collectionsService.delete(req.params.collection);
const collectionKey = req.params.collection.includes(',')
? req.params.collection.split(',')
: req.params.collection;
await collectionsService.delete(collectionKey as any);
res.end();
})

View File

@@ -1,74 +1,254 @@
import database from '../database';
import database, { schemaInspector } from '../database';
import { AbstractServiceOptions, Accountability, Collection } from '../types';
import Knex from 'knex';
import ItemsService from '../services/items';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
import SchemaInspector from 'knex-schema-inspector';
import FieldsService from '../services/fields';
import { omit } from 'lodash';
import ItemsService from '../services/items';
export default class CollectionsService extends ItemsService {
export default class CollectionsService {
knex: Knex;
accountability: Accountability | null;
itemsService: ItemsService;
fieldsService: FieldsService;
constructor(options?: AbstractServiceOptions) {
super('directus_collections', options);
this.knex = options?.knex || database;
this.accountability = options?.accountability || null;
this.itemsService = new ItemsService('directus_collections', options);
this.fieldsService = new FieldsService(options);
}
create(data: Partial<Collection>[]): Promise<string[]>;
create(data: Partial<Collection>): Promise<string>;
async create(data: Partial<Collection> | Partial<Collection>[]): Promise<string | string[]> {
const payloads = Array.isArray(data) ? data : [data];
if (this.accountability && this.accountability.admin !== true) {
throw new ForbiddenException('Only admins can perform this action.');
}
// We'll create the fields separately. We don't want them to be inserted relationally
const payloadsWithoutFields = payloads.map((payload) => omit(payload, 'fields'));
const payloads = (Array.isArray(data) ? data : [data]).map((collection) => {
if (!collection.fields) collection.fields = [];
await this.itemsService.create(payloadsWithoutFields);
for (const payload of payloads) {
// @TODO add basic validation to ensure all used fields are provided before attempting to save
await this.knex.schema.createTable(payload.collection!, async (table) => {
for (const field of payload.fields!) {
await this.fieldsService.createField(payload.collection!, field, table);
collection.fields = collection.fields.map((field) => {
if (field.system) {
field.system = {
...field.system,
field: field.field,
collection: collection.collection!,
};
}
return field;
});
return collection;
});
const createdCollections: string[] = [];
await this.knex.transaction(async (trx) => {
const schemaInspector = SchemaInspector(trx);
const fieldsService = new FieldsService({ knex: trx });
const collectionItemsService = new ItemsService('directus_collections', {
knex: trx,
accountability: this.accountability,
});
const fieldItemsService = new ItemsService('directus_fields', {
knex: trx,
accountability: this.accountability,
});
for (const payload of payloads) {
if (!payload.collection) {
throw new InvalidPayloadException(`The "collection" key is required.`);
}
if (await schemaInspector.hasTable(payload.collection)) {
throw new InvalidPayloadException(
`Collection "${payload.collection}" already exists.`
);
}
await trx.schema.createTable(payload.collection, (table) => {
for (const field of payload.fields!) {
fieldsService.addColumnToTable(table, field);
}
});
const collectionInfo = omit(payload, 'fields');
await collectionItemsService.create(collectionInfo);
const fieldPayloads = payload
.fields!.filter((field) => field.system)
.map((field) => field.system);
await fieldItemsService.create(fieldPayloads);
createdCollections.push(payload.collection);
}
});
return Array.isArray(data) ? createdCollections : createdCollections[0];
}
readByKey(collection: string[]): Promise<Collection[]>;
readByKey(collection: string): Promise<Collection>;
async readByKey(collection: string | string[]): Promise<Collection | Collection[]> {
const collectionItemsService = new ItemsService('directus_collections', {
knex: this.knex,
accountability: this.accountability,
});
const collectionKeys = Array.isArray(collection) ? collection : [collection];
if (this.accountability && this.accountability.admin !== true) {
const permissions = await this.knex
.select('collection')
.from('directus_permissions')
.where({ operation: 'read' })
.where({ role: this.accountability.role })
.whereIn('collection', collectionKeys);
if (collectionKeys.length !== permissions.length) {
const collectionsYouHavePermissionToRead = permissions.map(
({ collection }) => collection
);
for (const collectionKey of collectionKeys) {
if (collectionsYouHavePermissionToRead.includes(collectionKey) === false) {
throw new ForbiddenException(
`You don't have access to the "${collectionKey}" collection.`
);
}
}
}
}
const tablesInDatabase = await schemaInspector.tableInfo();
const tables = tablesInDatabase.filter((table) => collectionKeys.includes(table.name));
const system: any[] = await collectionItemsService.readByQuery({
filter: { collection: { _in: collectionKeys } },
});
const collections: Collection[] = [];
for (const table of tables) {
const collection: Collection = {
collection: table.name,
system: system.find((systemInfo) => systemInfo.collection === table.name) || null,
database: table,
};
collections.push(collection);
}
return Array.isArray(collection) ? collections : collections[0];
}
/** @todo, read by query without query support is a bit ironic, isnt it */
async readByQuery(): Promise<Collection[]> {
const collectionItemsService = new ItemsService('directus_collections');
let tablesInDatabase = await schemaInspector.tableInfo();
if (this.accountability && this.accountability.admin !== true) {
const collectionsYouHavePermissionToRead: string[] = (
await this.knex.select('collection').from('directus_permissions').where({
role: this.accountability.role,
operation: 'read',
})
).map(({ collection }) => collection);
tablesInDatabase = tablesInDatabase.filter((table) => {
return collectionsYouHavePermissionToRead.includes(table.name);
});
}
const collectionNames = payloads.map((payload) => payload.collection!);
return Array.isArray(data) ? collectionNames : collectionNames[0];
const tablesToFetchInfoFor = tablesInDatabase.map((table) => table.name);
const system: any[] = await collectionItemsService.readByQuery({
filter: { collection: { _in: tablesToFetchInfoFor } },
});
const collections: Collection[] = [];
for (const table of tablesInDatabase) {
const collection: Collection = {
collection: table.name,
system: system.find((systemInfo) => systemInfo.collection === table.name),
database: table,
};
collections.push(collection);
}
return collections;
}
/**
* @todo
* update w/ nested fields
* @NOTE
* We only suppport updating the content in directus_collections
*/
update(data: Partial<Collection>, keys: string[]): Promise<string[]>;
update(data: Partial<Collection>, key: string): Promise<string>;
update(data: Partial<Collection>[]): Promise<string[]>;
async update(
data: Partial<Collection> | Partial<Collection>[],
key?: string | string[]
): Promise<string | string[]> {
const collectionItemsService = new ItemsService('directus_collections', {
knex: this.knex,
accountability: this.accountability,
});
if (data && key) {
const payload = data as Partial<Collection>;
if (!payload.system) {
throw new InvalidPayloadException(`"system" key is required`);
}
return (await collectionItemsService.update(payload.system!, key as any)) as
| string
| string[];
}
const payloads = Array.isArray(data) ? data : [data];
const collectionUpdates = payloads.map((collection) => {
return {
...collection.system,
collection: collection.collection,
};
});
await collectionItemsService.update(collectionUpdates);
return key!;
}
delete(collection: string): Promise<string>;
delete(collections: string[]): Promise<string[]>;
async delete(collection: string | string[]): Promise<string | string[]> {
const collections = Array.isArray(collection) ? collection : [collection];
delete(collection: string): Promise<string>;
async delete(collection: string[] | string): Promise<string[] | string> {
if (this.accountability && this.accountability.admin !== true) {
throw new ForbiddenException('Only admins can perform this action.');
}
/**
* @todo check permissions manually
* this.itemsService.delete does the permissions check, but we have to delete the records from fields/relations first
* to adhere to the foreign key constraints
*/
const tablesInDatabase = await schemaInspector.tables();
await this.knex('directus_fields').delete().whereIn('collection', collections);
const collectionKeys = Array.isArray(collection) ? collection : [collection];
for (const collectionKey of collectionKeys) {
if (tablesInDatabase.includes(collectionKey) === false) {
throw new InvalidPayloadException(`Collection "${collectionKey}" doesn't exist.`);
}
}
await this.knex('directus_fields').delete().whereIn('collection', collectionKeys);
await this.knex('directus_relations')
.delete()
.whereIn('many_collection', collections)
.orWhereIn('one_collection', collections);
.whereIn('many_collection', collectionKeys)
.orWhereIn('one_collection', collectionKeys);
await this.itemsService.delete(collection as any);
const collectionItemsService = new ItemsService('directus_collections', {
knex: this.knex,
accountability: this.accountability,
});
await collectionItemsService.delete(collectionKeys);
for (const collectionName of collections) {
await this.knex.schema.dropTable(collectionName);
for (const collectionKey of collectionKeys) {
await this.knex.schema.dropTable(collectionKey);
}
return collection;