From 0deaa77f0be97a30af9e9867da7f4c07ede00ea0 Mon Sep 17 00:00:00 2001 From: rijkvanzanten Date: Tue, 14 Jul 2020 13:11:05 -0400 Subject: [PATCH] [WIP] Add processAST to PermissionsService, rely on AST instead of Query in services --- src/exceptions/forbidden.ts | 2 +- src/middleware/authenticate.ts | 13 +- ...ate-collection.ts => collection-exists.ts} | 9 +- src/middleware/example.json | 16 -- src/middleware/sanitize-query.ts | 26 +-- src/middleware/validate-query.ts | 67 -------- src/routes/activity.ts | 20 +-- src/routes/collections.ts | 14 +- src/routes/fields.ts | 5 +- src/routes/files.ts | 16 +- src/routes/folders.ts | 15 +- src/routes/items.ts | 34 ++-- src/routes/permissions.ts | 5 +- src/routes/presets.ts | 8 +- src/routes/relations.ts | 9 +- src/routes/revisions.ts | 7 +- src/routes/roles.ts | 9 +- src/routes/settings.ts | 9 +- src/routes/users.ts | 11 +- src/routes/webhooks.ts | 7 +- src/services/activity.ts | 2 +- src/services/collections.ts | 30 ++-- src/services/files.ts | 2 +- src/services/folders.ts | 2 +- src/services/items.ts | 20 +-- src/services/permissions.ts | 149 +++++++++++++++++- src/services/presets.ts | 2 +- src/services/relations.ts | 2 +- src/services/revisions.ts | 2 +- src/services/roles.ts | 2 +- src/services/settings.ts | 2 +- src/services/users.ts | 2 +- src/services/webhooks.ts | 2 +- src/types/express.d.ts | 7 +- .../{get-ast.ts => get-ast-from-query.ts} | 57 ++++--- 35 files changed, 316 insertions(+), 269 deletions(-) rename src/middleware/{validate-collection.ts => collection-exists.ts} (70%) delete mode 100644 src/middleware/example.json delete mode 100644 src/middleware/validate-query.ts rename src/utils/{get-ast.ts => get-ast-from-query.ts} (64%) diff --git a/src/exceptions/forbidden.ts b/src/exceptions/forbidden.ts index 4b0e6b88fa..195b0ab6c3 100644 --- a/src/exceptions/forbidden.ts +++ b/src/exceptions/forbidden.ts @@ -2,6 +2,6 @@ import { BaseException } from './base'; export class ForbiddenException extends BaseException { constructor(message = `You don't have permission to access this.`) { - super(message, 403, 'NO_PERMISSION_TO_ACCESS'); + super(message, 403, 'NO_PERMISSION'); } } diff --git a/src/middleware/authenticate.ts b/src/middleware/authenticate.ts index 4ef611c685..98feb551c4 100644 --- a/src/middleware/authenticate.ts +++ b/src/middleware/authenticate.ts @@ -9,7 +9,10 @@ import { InvalidCredentialsException } from '../exceptions'; * Verify the passed JWT and assign the user ID and role to `req` */ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => { - /** @todo base this on a validation middleware on permissions */ + req.user = null; + req.role = null; + req.admin = false; + if (!req.token) return next(); if (isJWT(req.token)) { @@ -26,14 +29,17 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => { } const user = await database - .select('role') + .select('role', 'directus_roles.admin') .from('directus_users') - .where({ id: payload.id }) + .leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id') + .where({ 'directus_users.id': payload.id }) .first(); /** @TODO verify user status */ + req.user = payload.id; req.role = user.role; + req.admin = user.admin; return next(); } @@ -41,6 +47,7 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => { * @TODO * Implement static tokens * + * @NOTE * We'll silently ignore wrong tokens. This makes sure we prevent brute-forcing static tokens */ return next(); diff --git a/src/middleware/validate-collection.ts b/src/middleware/collection-exists.ts similarity index 70% rename from src/middleware/validate-collection.ts rename to src/middleware/collection-exists.ts index bff8b6794e..6320102f2a 100644 --- a/src/middleware/validate-collection.ts +++ b/src/middleware/collection-exists.ts @@ -5,15 +5,15 @@ import { RequestHandler } from 'express'; import asyncHandler from 'express-async-handler'; import database from '../database'; -import { CollectionNotFoundException } from '../exceptions'; +import { ForbiddenException } from '../exceptions'; -const validateCollection: RequestHandler = asyncHandler(async (req, res, next) => { +const collectionExists: RequestHandler = asyncHandler(async (req, res, next) => { if (!req.params.collection) return next(); const exists = await database.schema.hasTable(req.params.collection); if (exists === false) { - throw new CollectionNotFoundException(req.params.collection); + throw new ForbiddenException(); } req.collection = req.params.collection; @@ -23,9 +23,10 @@ const validateCollection: RequestHandler = asyncHandler(async (req, res, next) = .from('directus_collections') .where({ collection: req.collection }) .first(); + req.single = single; return next(); }); -export default validateCollection; +export default collectionExists; diff --git a/src/middleware/example.json b/src/middleware/example.json deleted file mode 100644 index b709d68972..0000000000 --- a/src/middleware/example.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "_or": [ - { - "_and": [ - { "email": { "_eq": "rijk@rngr.org" }}, - { "first_name": { "_eq": "Rijk" }} - ] - }, - { - "_and": [ - { "email": { "_eq": "ben@rngr.org" } }, - { "first_name": { "_eq": "Ben" } } - ] - } - ] -} diff --git a/src/middleware/sanitize-query.ts b/src/middleware/sanitize-query.ts index e49c57f20b..fd1408dd46 100644 --- a/src/middleware/sanitize-query.ts +++ b/src/middleware/sanitize-query.ts @@ -11,11 +11,10 @@ import logger from '../logger'; const sanitizeQuery: RequestHandler = (req, res, next) => { if (!req.query) return; - const query: Query = {}; - - if (req.query.fields) { - query.fields = sanitizeFields(req.query.fields); - } + const query: Query = { + fields: sanitizeFields(req.query.fields) || ['*'], + limit: sanitizeLimit(req.query.limit) || 100, + }; if (req.query.sort) { query.sort = sanitizeSort(req.query.sort); @@ -25,13 +24,6 @@ const sanitizeQuery: RequestHandler = (req, res, next) => { query.filter = sanitizeFilter(req.query.filter); } - if (req.query.limit) { - query.limit = sanitizeLimit(req.query.limit); - } else { - /** @todo is this the right place to set these defaults? */ - query.limit = 100; - } - if (req.query.limit == '-1') { delete query.limit; } @@ -56,6 +48,13 @@ const sanitizeQuery: RequestHandler = (req, res, next) => { query.search = req.query.search; } + if (req.permissions) { + query.filter = { + ...(query.filter || {}), + ...(req.permissions.permissions || {}), + }; + } + req.sanitizedQuery = query; return next(); }; @@ -63,6 +62,8 @@ const sanitizeQuery: RequestHandler = (req, res, next) => { export default sanitizeQuery; function sanitizeFields(rawFields: any) { + if (!rawFields) return; + let fields: string[] = []; if (typeof rawFields === 'string') fields = rawFields.split(','); @@ -104,6 +105,7 @@ function sanitizeFilter(rawFilter: any) { } function sanitizeLimit(rawLimit: any) { + if (!rawLimit) return null; return Number(rawLimit); } diff --git a/src/middleware/validate-query.ts b/src/middleware/validate-query.ts deleted file mode 100644 index d0d6d3c5bf..0000000000 --- a/src/middleware/validate-query.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Validates query parameters. - * We'll check if all fields you're trying to access exist - * - * This has to be run after sanitizeQuery - */ - -import { RequestHandler } from 'express'; -import { Query } from '../types/query'; -import asyncHandler from 'express-async-handler'; -import { InvalidQueryException } from '../exceptions'; -import hasFields from '../utils/has-fields'; - -const validateQuery: RequestHandler = asyncHandler(async (req, res, next) => { - if (!req.collection) return next(); - if (!req.sanitizedQuery) return next(); - - const query: Query = req.sanitizedQuery; - - await Promise.all([ - validateParams(req.collection, query), - validateFields(req.collection, query), - validateMeta(query), - ]); - - return next(); -}); - -async function validateParams(collection: string, query: Query) { - if (query.offset && query.page) { - throw new InvalidQueryException( - `You can't have both the offset and page query parameters enabled.` - ); - } -} - -async function validateFields(collection: string, query: Query) { - /** - * @todo combine this with permissions (?) - */ - /** - * @todo use /utils/has-fields - */ - // const fieldsToCheck = new Set(); - // if (query.fields) { - // /** @todo support relationships in here */ - // query.fields.forEach((field) => fieldsToCheck.add(field)); - // } - // if (query.sort) { - // query.sort.forEach((sort) => fieldsToCheck.add(sort.column)); - // } - // /** @todo swap with more efficient schemaInspector version */ - // const fieldsExist = await hasFields(collection, Array.from(fieldsToCheck)); - // Array.from(fieldsToCheck).forEach((field, index) => { - // const exists = fieldsExist[index]; - // if (exists === false) - // throw new InvalidQueryException(`Field ${field} doesn't exist in ${collection}.`); - // }); -} - -async function validateMeta(query: Query) { - if (!query.meta) return; - - return query.meta.every((metaField) => []); -} - -export default validateQuery; diff --git a/src/routes/activity.ts b/src/routes/activity.ts index 43b846ffcd..751520d6a7 100644 --- a/src/routes/activity.ts +++ b/src/routes/activity.ts @@ -1,30 +1,27 @@ import express from 'express'; import asyncHandler from 'express-async-handler'; import sanitizeQuery from '../middleware/sanitize-query'; -import validateQuery from '../middleware/validate-query'; import * as ActivityService from '../services/activity'; import useCollection from '../middleware/use-collection'; const router = express.Router(); +router.use(useCollection('directus_activity')); + router.get( '/', - useCollection('directus_activity'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { - const records = await ActivityService.readActivities(req.sanitizedQuery); - return res.json({ - data: records || null, - }); + // const records = await ActivityService.readActivities(req.sanitizedQuery); + // return res.json({ + // data: records || null, + // }); }) ); router.get( '/:pk', - useCollection('directus_activity'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const record = await ActivityService.readActivity(req.params.pk, req.sanitizedQuery); @@ -36,9 +33,7 @@ router.get( router.post( '/comment', - useCollection('directus_activity'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const primaryKey = await ActivityService.createActivity({ ...req.body, @@ -58,9 +53,7 @@ router.post( router.patch( '/comment/:pk', - useCollection('directus_activity'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const primaryKey = await ActivityService.updateActivity(req.params.pk, req.body, { user: req.user, @@ -78,7 +71,6 @@ router.patch( router.delete( '/comment/:pk', - useCollection('directus_activity'), asyncHandler(async (req, res) => { await ActivityService.deleteActivity(req.params.pk, { user: req.user, diff --git a/src/routes/collections.ts b/src/routes/collections.ts index bc64717d1f..38d823e1fb 100644 --- a/src/routes/collections.ts +++ b/src/routes/collections.ts @@ -1,11 +1,11 @@ import { Router } from 'express'; import asyncHandler from 'express-async-handler'; import sanitizeQuery from '../middleware/sanitize-query'; -import validateQuery from '../middleware/validate-query'; import * as CollectionsService from '../services/collections'; -import database, { schemaInspector } from '../database'; +import { schemaInspector } from '../database'; import { InvalidPayloadException, CollectionNotFoundException } from '../exceptions'; import Joi from '@hapi/joi'; +import useCollection from '../middleware/use-collection'; const router = Router(); @@ -25,6 +25,7 @@ const collectionSchema = Joi.object({ router.post( '/', + useCollection('directus_collections'), asyncHandler(async (req, res) => { const { error } = collectionSchema.validate(req.body); if (error) throw new InvalidPayloadException(error.message); @@ -41,18 +42,18 @@ router.post( router.get( '/', + useCollection('directus_collections'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { - const collections = await CollectionsService.readAll(req.sanitizedQuery); - res.json({ data: collections || null }); + // const collections = await CollectionsService.readAll(req.sanitizedQuery); + // res.json({ data: collections || null }); }) ); router.get( '/:collection', + useCollection('directus_collections'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const exists = await schemaInspector.hasTable(req.params.collection); @@ -68,6 +69,7 @@ router.get( router.delete( '/:collection', + useCollection('directus_collections'), asyncHandler(async (req, res) => { if ((await schemaInspector.hasTable(req.params.collection)) === false) { throw new CollectionNotFoundException(req.params.collection); diff --git a/src/routes/fields.ts b/src/routes/fields.ts index a8e5530e88..bdcd2be6e3 100644 --- a/src/routes/fields.ts +++ b/src/routes/fields.ts @@ -1,14 +1,17 @@ import { Router } from 'express'; import asyncHandler from 'express-async-handler'; import * as FieldsService from '../services/fields'; -import validateCollection from '../middleware/validate-collection'; +import validateCollection from '../middleware/collection-exists'; import { schemaInspector } from '../database'; import { FieldNotFoundException, InvalidPayloadException } from '../exceptions'; import Joi from '@hapi/joi'; import { Field } from '../types/field'; +import useCollection from '../middleware/use-collection'; const router = Router(); +router.use(useCollection('directus_fields')); + router.get( '/', asyncHandler(async (req, res) => { diff --git a/src/routes/files.ts b/src/routes/files.ts index cfefde28ff..15436da535 100644 --- a/src/routes/files.ts +++ b/src/routes/files.ts @@ -2,7 +2,6 @@ import express from 'express'; import asyncHandler from 'express-async-handler'; import Busboy from 'busboy'; import sanitizeQuery from '../middleware/sanitize-query'; -import validateQuery from '../middleware/validate-query'; import * as FilesService from '../services/files'; import logger from '../logger'; import { InvalidPayloadException } from '../exceptions'; @@ -10,6 +9,8 @@ import useCollection from '../middleware/use-collection'; const router = express.Router(); +router.use(useCollection('directus_files')); + const multipartHandler = (operation: 'create' | 'update') => asyncHandler(async (req, res, next) => { const busboy = new Busboy({ headers: req.headers }); @@ -93,24 +94,20 @@ const multipartHandler = (operation: 'create' | 'update') => return req.pipe(busboy); }); -router.post('/', useCollection('directus_files'), multipartHandler('create')); +router.post('/', sanitizeQuery, multipartHandler('create')); router.get( '/', - useCollection('directus_files'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { - const records = await FilesService.readFiles(req.sanitizedQuery); - return res.json({ data: records || null }); + // const records = await FilesService.readFiles(req.sanitizedQuery); + // return res.json({ data: records || null }); }) ); router.get( '/:pk', - useCollection('directus_files'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const record = await FilesService.readFile(req.params.pk, req.sanitizedQuery); return res.json({ data: record || null }); @@ -119,7 +116,7 @@ router.get( router.patch( '/:pk', - useCollection('directus_files'), + sanitizeQuery, asyncHandler(async (req, res, next) => { let file: Record; @@ -140,7 +137,6 @@ router.patch( router.delete( '/:pk', - useCollection('directus_files'), asyncHandler(async (req, res) => { await FilesService.deleteFile(req.params.pk, { ip: req.ip, diff --git a/src/routes/folders.ts b/src/routes/folders.ts index b83f4072b9..5d60b7dd5f 100644 --- a/src/routes/folders.ts +++ b/src/routes/folders.ts @@ -1,15 +1,15 @@ import express from 'express'; import asyncHandler from 'express-async-handler'; import sanitizeQuery from '../middleware/sanitize-query'; -import validateQuery from '../middleware/validate-query'; import useCollection from '../middleware/use-collection'; import * as FoldersService from '../services/folders'; const router = express.Router(); +router.use(useCollection('directus_folders')); + router.post( '/', - useCollection('directus_folders'), asyncHandler(async (req, res) => { const primaryKey = await FoldersService.createFolder(req.body, { ip: req.ip, @@ -24,20 +24,16 @@ router.post( router.get( '/', - useCollection('directus_folders'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { - const records = await FoldersService.readFolders(req.sanitizedQuery); - return res.json({ data: records || null }); + // const records = await FoldersService.readFolders(req.sanitizedQuery); + // return res.json({ data: records || null }); }) ); router.get( '/:pk', - useCollection('directus_folders'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const record = await FoldersService.readFolder(req.params.pk, req.sanitizedQuery); return res.json({ data: record || null }); @@ -46,7 +42,7 @@ router.get( router.patch( '/:pk', - useCollection('directus_folders'), + sanitizeQuery, asyncHandler(async (req, res) => { const primaryKey = await FoldersService.updateFolder(req.params.pk, req.body, { ip: req.ip, @@ -62,7 +58,6 @@ router.patch( router.delete( '/:pk', - useCollection('directus_folders'), asyncHandler(async (req, res) => { await FoldersService.deleteFolder(req.params.pk, { ip: req.ip, diff --git a/src/routes/items.ts b/src/routes/items.ts index ca8c1ebff7..3196845f3f 100644 --- a/src/routes/items.ts +++ b/src/routes/items.ts @@ -2,18 +2,18 @@ import express from 'express'; import asyncHandler from 'express-async-handler'; import * as ItemsService from '../services/items'; import sanitizeQuery from '../middleware/sanitize-query'; -import validateCollection from '../middleware/validate-collection'; -import validateQuery from '../middleware/validate-query'; +import collectionExists from '../middleware/collection-exists'; import * as MetaService from '../services/meta'; +import * as PermissionsService from '../services/permissions'; import { RouteNotFoundException } from '../exceptions'; +import getASTFromQuery from '../utils/get-ast-from-query'; const router = express.Router(); router.post( '/:collection', - validateCollection, + collectionExists, sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { if (req.single) { throw new RouteNotFoundException(req.path); @@ -33,14 +33,17 @@ router.post( router.get( '/:collection', - validateCollection, + collectionExists, sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { + let ast = await getASTFromQuery(req.role, req.collection, req.sanitizedQuery); + + ast = await PermissionsService.processAST(req.role, ast); + const [records, meta] = await Promise.all([ req.single - ? ItemsService.readSingleton(req.collection, req.sanitizedQuery) - : ItemsService.readItems(req.collection, req.sanitizedQuery), + ? ItemsService.readSingleton(req.collection, ast) + : ItemsService.readItems(req.collection, ast), MetaService.getMetaForQuery(req.collection, req.sanitizedQuery), ]); @@ -53,9 +56,8 @@ router.get( router.get( '/:collection/:pk', - validateCollection, + collectionExists, sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { if (req.single) { throw new RouteNotFoundException(req.path); @@ -75,9 +77,8 @@ router.get( router.patch( '/:collection', - validateCollection, + collectionExists, sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { if (req.single === false) { throw new RouteNotFoundException(req.path); @@ -89,17 +90,16 @@ router.patch( user: req.user, }); - const item = await ItemsService.readSingleton(req.collection, req.sanitizedQuery); + // const item = await ItemsService.readSingleton(req.collection, req.sanitizedQuery); - return res.json({ data: item || null }); + // return res.json({ data: item || null }); }) ); router.patch( '/:collection/:pk', - validateCollection, + collectionExists, sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { if (req.single) { throw new RouteNotFoundException(req.path); @@ -119,7 +119,7 @@ router.patch( router.delete( '/:collection/:pk', - validateCollection, + collectionExists, asyncHandler(async (req, res) => { await ItemsService.deleteItem(req.collection, req.params.pk, { ip: req.ip, diff --git a/src/routes/permissions.ts b/src/routes/permissions.ts index 51f192ddfb..1e5a2ec34d 100644 --- a/src/routes/permissions.ts +++ b/src/routes/permissions.ts @@ -1,7 +1,6 @@ import express from 'express'; import asyncHandler from 'express-async-handler'; import sanitizeQuery from '../middleware/sanitize-query'; -import validateQuery from '../middleware/validate-query'; import * as PermissionsService from '../services/permissions'; import useCollection from '../middleware/use-collection'; @@ -27,10 +26,9 @@ router.get( '/', useCollection('directus_permissions'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const item = await PermissionsService.readPermissions(req.sanitizedQuery); - return res.json({ data: item || null }); + // return res.json({ data: item || null }); }) ); @@ -38,7 +36,6 @@ router.get( '/:pk', useCollection('directus_permissions'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const record = await PermissionsService.readPermission( Number(req.params.pk), diff --git a/src/routes/presets.ts b/src/routes/presets.ts index 3b5fe2e82e..0a319aa99d 100644 --- a/src/routes/presets.ts +++ b/src/routes/presets.ts @@ -1,7 +1,6 @@ import express from 'express'; import asyncHandler from 'express-async-handler'; import sanitizeQuery from '../middleware/sanitize-query'; -import validateQuery from '../middleware/validate-query'; import * as CollectionPresetsService from '../services/presets'; import useCollection from '../middleware/use-collection'; @@ -29,10 +28,9 @@ router.get( '/', useCollection('directus_presets'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { - const records = await CollectionPresetsService.readCollectionPresets(req.sanitizedQuery); - return res.json({ data: records || null }); + // const records = await CollectionPresetsService.readCollectionPresets(req.sanitizedQuery); + // return res.json({ data: records || null }); }) ); @@ -40,7 +38,6 @@ router.get( '/:pk', useCollection('directus_presets'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const record = await CollectionPresetsService.readCollectionPreset( req.params.pk, @@ -54,7 +51,6 @@ router.patch( '/:pk', useCollection('directus_presets'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const primaryKey = await CollectionPresetsService.updateCollectionPreset( req.params.pk, diff --git a/src/routes/relations.ts b/src/routes/relations.ts index 4f4c62be14..fe91168eef 100644 --- a/src/routes/relations.ts +++ b/src/routes/relations.ts @@ -1,7 +1,6 @@ import express from 'express'; import asyncHandler from 'express-async-handler'; import sanitizeQuery from '../middleware/sanitize-query'; -import validateQuery from '../middleware/validate-query'; import * as RelationsService from '../services/relations'; import useCollection from '../middleware/use-collection'; @@ -11,7 +10,6 @@ router.post( '/', useCollection('directus_relations'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const primaryKey = await RelationsService.createRelation(req.body, { ip: req.ip, @@ -27,10 +25,9 @@ router.get( '/', useCollection('directus_relations'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { - const records = await RelationsService.readRelations(req.sanitizedQuery); - return res.json({ data: records || null }); + // const records = await RelationsService.readRelations(req.sanitizedQuery); + // return res.json({ data: records || null }); }) ); @@ -38,7 +35,6 @@ router.get( '/:pk', useCollection('directus_relations'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const record = await RelationsService.readRelation(req.params.pk, req.sanitizedQuery); return res.json({ data: record || null }); @@ -49,7 +45,6 @@ router.patch( '/:pk', useCollection('directus_relations'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const primaryKey = await RelationsService.updateRelation(req.params.pk, req.body, { ip: req.ip, diff --git a/src/routes/revisions.ts b/src/routes/revisions.ts index e604e7f413..ca72f46883 100644 --- a/src/routes/revisions.ts +++ b/src/routes/revisions.ts @@ -1,7 +1,6 @@ import express from 'express'; import asyncHandler from 'express-async-handler'; import sanitizeQuery from '../middleware/sanitize-query'; -import validateQuery from '../middleware/validate-query'; import * as RevisionsService from '../services/revisions'; import useCollection from '../middleware/use-collection'; @@ -11,10 +10,9 @@ router.get( '/', useCollection('directus_revisions'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { - const records = await RevisionsService.readRevisions(req.sanitizedQuery); - return res.json({ data: records || null }); + // const records = await RevisionsService.readRevisions(req.sanitizedQuery); + // return res.json({ data: records || null }); }) ); @@ -22,7 +20,6 @@ router.get( '/:pk', useCollection('directus_revisions'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const record = await RevisionsService.readRevision(req.params.pk, req.sanitizedQuery); return res.json({ data: record || null }); diff --git a/src/routes/roles.ts b/src/routes/roles.ts index 4e8cf6d090..272b8d41d7 100644 --- a/src/routes/roles.ts +++ b/src/routes/roles.ts @@ -1,7 +1,6 @@ import express from 'express'; import asyncHandler from 'express-async-handler'; import sanitizeQuery from '../middleware/sanitize-query'; -import validateQuery from '../middleware/validate-query'; import * as RolesService from '../services/roles'; import useCollection from '../middleware/use-collection'; @@ -11,7 +10,6 @@ router.post( '/', useCollection('directus_roles'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const primaryKey = await RolesService.createRole(req.body, { ip: req.ip, @@ -27,10 +25,9 @@ router.get( '/', useCollection('directus_roles'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { - const records = await RolesService.readRoles(req.sanitizedQuery); - return res.json({ data: records || null }); + // const records = await RolesService.readRoles(req.sanitizedQuery); + // return res.json({ data: records || null }); }) ); @@ -38,7 +35,6 @@ router.get( '/:pk', useCollection('directus_roles'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const record = await RolesService.readRole(req.params.pk, req.sanitizedQuery); return res.json({ data: record || null }); @@ -49,7 +45,6 @@ router.patch( '/:pk', useCollection('directus_roles'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const primaryKey = await RolesService.updateRole(req.params.pk, req.body, { ip: req.ip, diff --git a/src/routes/settings.ts b/src/routes/settings.ts index bfab4d2afa..f5b84ff546 100644 --- a/src/routes/settings.ts +++ b/src/routes/settings.ts @@ -1,7 +1,6 @@ import express from 'express'; import asyncHandler from 'express-async-handler'; import sanitizeQuery from '../middleware/sanitize-query'; -import validateQuery from '../middleware/validate-query'; import * as SettingsService from '../services/settings'; import useCollection from '../middleware/use-collection'; @@ -11,10 +10,9 @@ router.get( '/', useCollection('directus_settings'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const records = await SettingsService.readSettings(req.sanitizedQuery); - return res.json({ data: records || null }); + // return res.json({ data: records || null }); }) ); @@ -22,7 +20,6 @@ router.patch( '/', useCollection('directus_settings'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { await SettingsService.updateSettings(req.body, { ip: req.ip, @@ -30,9 +27,9 @@ router.patch( user: req.user, }); - const record = await SettingsService.readSettings(req.sanitizedQuery); + // const record = await SettingsService.readSettings(req.sanitizedQuery); - return res.json({ data: record || null }); + // return res.json({ data: record || null }); }) ); diff --git a/src/routes/users.ts b/src/routes/users.ts index 32130e8e5e..1839235072 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -1,7 +1,6 @@ import express from 'express'; import asyncHandler from 'express-async-handler'; import sanitizeQuery from '../middleware/sanitize-query'; -import validateQuery from '../middleware/validate-query'; import * as UsersService from '../services/users'; import Joi from '@hapi/joi'; import { InvalidPayloadException, InvalidCredentialsException } from '../exceptions'; @@ -13,7 +12,6 @@ router.post( '/', useCollection('directus_users'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const primaryKey = await UsersService.createUser(req.body, { ip: req.ip, @@ -29,10 +27,9 @@ router.get( '/', useCollection('directus_users'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { - const item = await UsersService.readUsers(req.sanitizedQuery); - return res.json({ data: item || null }); + // const item = await UsersService.readUsers(req.sanitizedQuery); + // return res.json({ data: item || null }); }) ); @@ -40,7 +37,6 @@ router.get( '/me', useCollection('directus_users'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { if (!req.user) { throw new InvalidCredentialsException(); @@ -55,7 +51,6 @@ router.get( '/:pk', useCollection('directus_users'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const items = await UsersService.readUser(req.params.pk, req.sanitizedQuery); return res.json({ data: items || null }); @@ -66,7 +61,6 @@ router.patch( '/me', useCollection('directus_users'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { if (!req.user) { throw new InvalidCredentialsException(); @@ -87,7 +81,6 @@ router.patch( '/:pk', useCollection('directus_users'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const primaryKey = await UsersService.updateUser(req.params.pk, req.body, { ip: req.ip, diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts index 0d04dccac5..058ab9cd8e 100644 --- a/src/routes/webhooks.ts +++ b/src/routes/webhooks.ts @@ -1,7 +1,6 @@ import express from 'express'; import asyncHandler from 'express-async-handler'; import sanitizeQuery from '../middleware/sanitize-query'; -import validateQuery from '../middleware/validate-query'; import * as WebhooksService from '../services/webhooks'; import useCollection from '../middleware/use-collection'; @@ -27,10 +26,9 @@ router.get( '/', useCollection('directus_webhooks'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { - const records = await WebhooksService.readWebhooks(req.sanitizedQuery); - return res.json({ data: records || null }); + // const records = await WebhooksService.readWebhooks(req.sanitizedQuery); + // return res.json({ data: records || null }); }) ); @@ -38,7 +36,6 @@ router.get( '/:pk', useCollection('directus_webhooks'), sanitizeQuery, - validateQuery, asyncHandler(async (req, res) => { const record = await WebhooksService.readWebhook(req.params.pk, req.sanitizedQuery); return res.json({ data: record || null }); diff --git a/src/services/activity.ts b/src/services/activity.ts index d6f854afdc..f601468b26 100644 --- a/src/services/activity.ts +++ b/src/services/activity.ts @@ -17,7 +17,7 @@ export const createActivity = async (data: Record) => { }; export const readActivities = async (query?: Query) => { - return await ItemsService.readItems('directus_activity', query); + // return await ItemsService.readItems('directus_activity', query); }; export const readActivity = async (pk: string | number, query?: Query) => { diff --git a/src/services/collections.ts b/src/services/collections.ts index 3bba16f8bb..30ebf4410b 100644 --- a/src/services/collections.ts +++ b/src/services/collections.ts @@ -73,25 +73,25 @@ export const create = async (payload: any, accountability: Accountability) => { export const readAll = async (query?: Query) => { const [tables, collections] = await Promise.all([ schemaInspector.tableInfo(), - ItemsService.readItems('directus_collections', query), + // ItemsService.readItems('directus_collections', query), ]); - const data = tables.map((table) => { - const collectionInfo = collections.find((collection) => { - return collection.collection === table.name; - }); + // const data = tables.map((table) => { + // const collectionInfo = collections.find((collection) => { + // return collection.collection === table.name; + // }); - return { - collection: table.name, - note: table.comment, - hidden: collectionInfo?.hidden || false, - single: collectionInfo?.single || false, - icon: collectionInfo?.icon || null, - translation: collectionInfo?.translation || null, - }; - }); + // return { + // collection: table.name, + // note: table.comment, + // hidden: collectionInfo?.hidden || false, + // single: collectionInfo?.single || false, + // icon: collectionInfo?.icon || null, + // translation: collectionInfo?.translation || null, + // }; + // }); - return data; + // return data; }; export const readOne = async (collection: string, query?: Query) => { diff --git a/src/services/files.ts b/src/services/files.ts index bcf7035c6f..e65c1f209e 100644 --- a/src/services/files.ts +++ b/src/services/files.ts @@ -57,7 +57,7 @@ export const createFile = async ( }; export const readFiles = async (query: Query) => { - return await ItemsService.readItems('directus_files', query); + // return await ItemsService.readItems('directus_files', query); }; export const readFile = async (pk: string | number, query: Query) => { diff --git a/src/services/folders.ts b/src/services/folders.ts index fd471b7682..b3f9a9bd6a 100644 --- a/src/services/folders.ts +++ b/src/services/folders.ts @@ -9,7 +9,7 @@ export const createFolder = async ( }; export const readFolders = async (query: Query) => { - return await ItemsService.readItems('directus_folders', query); + // return await ItemsService.readItems('directus_folders', query); }; export const readFolder = async (pk: string, query: Query) => { diff --git a/src/services/items.ts b/src/services/items.ts index 7ab26ba46c..9ee1f6f82c 100644 --- a/src/services/items.ts +++ b/src/services/items.ts @@ -1,9 +1,9 @@ import database, { schemaInspector } from '../database'; import { Query } from '../types/query'; import runAST from '../database/run-ast'; -import getAST from '../utils/get-ast'; +import getAST from '../utils/get-ast-from-query'; import * as PayloadService from './payload'; -import { Accountability } from '../types/accountability'; +import { Accountability, AST } from '../types'; import * as ActivityService from './activity'; import * as RevisionsService from './revisions'; @@ -82,9 +82,8 @@ export const createItem = async ( export const readItems = async >( collection: string, - query: Query = {} + ast: AST ): Promise => { - const ast = await getAST(collection, query); const records = await runAST(ast); return await PayloadService.processValues('read', collection, records); }; @@ -106,9 +105,10 @@ export const readItem = async ( }, }; - const ast = await getAST(collection, query); - const records = await runAST(ast); - return await PayloadService.processValues('read', collection, records[0]); + // const ast = await getAST(collection, query); + // const records = await runAST(ast); + // return await PayloadService.processValues('read', collection, records[0]); + return; }; export const updateItem = async ( @@ -172,8 +172,10 @@ export const deleteItem = async ( .where({ [primaryKeyField]: pk }); }; -export const readSingleton = async (collection: string, query: Query = {}) => { - const records = await readItems(collection, { ...query, limit: 1 }); +export const readSingleton = async (collection: string, ast: AST) => { + ast.query.limit = 1; + + const records = await readItems(collection, ast); const record = records[0]; if (!record) { diff --git a/src/services/permissions.ts b/src/services/permissions.ts index f7f3f85f6a..ca27516fac 100644 --- a/src/services/permissions.ts +++ b/src/services/permissions.ts @@ -1,5 +1,15 @@ -import { Accountability, Permission, Operation, Query } from '../types'; +import { + Accountability, + AST, + NestedCollectionAST, + FieldAST, + Operation, + Query, + Permission, +} from '../types'; import * as ItemsService from './items'; +import database from '../database'; +import { ForbiddenException } from '../exceptions'; export const createPermission = async ( data: Record, @@ -9,7 +19,7 @@ export const createPermission = async ( }; export const readPermissions = async (query: Query) => { - return await ItemsService.readItems('directus_permissions', query); + // return await ItemsService.readItems('directus_permissions', query); }; export const readPermission = async (pk: number, query: Query) => { @@ -52,7 +62,138 @@ export const authorize = async (operation: Operation, collection: string, role?: }; } - const records = await ItemsService.readItems('directus_permissions', query); + // const records = await ItemsService.readItems('directus_permissions', query); - return records[0]; + // return records[0]; +}; + +export const processAST = async (role: string | null, ast: AST): Promise => { + const collectionsRequested = getCollectionsFromAST(ast); + const permissionsForCollections = await database + .select('*') + .from('directus_permissions') + .whereIn( + 'collection', + collectionsRequested.map(({ collection }) => collection) + ) + .andWhere('role', role); + + validateCollections(); + + // 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); + + validateFields(ast); + + return ast; + + /** + * Traverses the AST and returns an array of all collections that are being fetched + */ + function getCollectionsFromAST(ast: AST | NestedCollectionAST) { + const collections = []; + + if (ast.type === 'collection') { + collections.push({ + collection: ast.name, + field: (ast as NestedCollectionAST).fieldKey + ? (ast as NestedCollectionAST).fieldKey + : null, + }); + } + + for (const subAST of ast.children) { + if (subAST.type === 'collection') { + collections.push(...getCollectionsFromAST(subAST)); + } + } + + 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; + const permissions = permissionsForCollections.find( + (permission) => permission.collection === collection + ); + const allowedFields = permissions.fields.split(','); + + for (const childAST of ast.children) { + if (childAST.type === 'collection') { + validateFields(childAST); + continue; + } + + const fieldKey = childAST.name; + if (allowedFields.includes(fieldKey) === false) { + throw new ForbiddenException( + `You don't have permission to access the "${fieldKey}" field.` + ); + } + } + } + } }; diff --git a/src/services/presets.ts b/src/services/presets.ts index f953a4d737..8012bec5c8 100644 --- a/src/services/presets.ts +++ b/src/services/presets.ts @@ -11,7 +11,7 @@ export const createCollectionPreset = async ( }; export const readCollectionPresets = async (query: Query) => { - return await ItemsService.readItems('directus_presets', query); + // return await ItemsService.readItems('directus_presets', query); }; export const readCollectionPreset = async (pk: string | number, query: Query) => { diff --git a/src/services/relations.ts b/src/services/relations.ts index 9c359f2d24..8ff4acb432 100644 --- a/src/services/relations.ts +++ b/src/services/relations.ts @@ -6,7 +6,7 @@ export const createRelation = async (data: Record, accountability: }; export const readRelations = async (query: Query) => { - return await ItemsService.readItems('directus_relations', query); + // return await ItemsService.readItems('directus_relations', query); }; export const readRelation = async (pk: string | number, query: Query) => { diff --git a/src/services/revisions.ts b/src/services/revisions.ts index 29041cee0e..9c425ae231 100644 --- a/src/services/revisions.ts +++ b/src/services/revisions.ts @@ -6,7 +6,7 @@ export const createRevision = async (data: Record) => { }; export const readRevisions = async (query: Query) => { - return await ItemsService.readItems('directus_revisions', query); + // return await ItemsService.readItems('directus_revisions', query); }; export const readRevision = async (pk: string | number, query: Query) => { diff --git a/src/services/roles.ts b/src/services/roles.ts index 30b18f5f86..cc357c8dd2 100644 --- a/src/services/roles.ts +++ b/src/services/roles.ts @@ -6,7 +6,7 @@ export const createRole = async (data: Record, accountability: Acco }; export const readRoles = async (query: Query) => { - return await ItemsService.readItems('directus_roles', query); + // return await ItemsService.readItems('directus_roles', query); }; export const readRole = async (pk: string | number, query: Query) => { diff --git a/src/services/settings.ts b/src/services/settings.ts index 6d7fafd7ca..06198767cf 100644 --- a/src/services/settings.ts +++ b/src/services/settings.ts @@ -3,7 +3,7 @@ import * as ItemsService from './items'; import { Accountability } from '../types'; export const readSettings = async (query: Query) => { - return await ItemsService.readSingleton('directus_settings', query); + // return await ItemsService.readSingleton('directus_settings', query); }; export const updateSettings = async (data: Record, accountability: Accountability) => { diff --git a/src/services/users.ts b/src/services/users.ts index 50afd35af9..ddce886f5d 100644 --- a/src/services/users.ts +++ b/src/services/users.ts @@ -11,7 +11,7 @@ export const createUser = async (data: Record, accountability: Acco }; export const readUsers = async (query?: Query) => { - return await ItemsService.readItems('directus_users', query); + // return await ItemsService.readItems('directus_users', query); }; export const readUser = async (pk: string | number, query?: Query) => { diff --git a/src/services/webhooks.ts b/src/services/webhooks.ts index 5de126e65f..c7e9d80b29 100644 --- a/src/services/webhooks.ts +++ b/src/services/webhooks.ts @@ -6,7 +6,7 @@ export const createWebhook = async (data: Record, accountability: A }; export const readWebhooks = async (query: Query) => { - return await ItemsService.readItems('directus_webhooks', query); + // return await ItemsService.readItems('directus_webhooks', query); }; export const readWebhook = async (pk: string | number, query: Query) => { diff --git a/src/types/express.d.ts b/src/types/express.d.ts index e5407f4bab..33db09d58f 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -9,9 +9,10 @@ export {}; declare global { namespace Express { export interface Request { - token?: string; - user?: string; - role?: string; + token: string; + user: string; + role: string | null; + admin: boolean; collection?: string; sanitizedQuery?: Record; single?: boolean; diff --git a/src/utils/get-ast.ts b/src/utils/get-ast-from-query.ts similarity index 64% rename from src/utils/get-ast.ts rename to src/utils/get-ast-from-query.ts index eb8e52230d..e095ed4f7f 100644 --- a/src/utils/get-ast.ts +++ b/src/utils/get-ast-from-query.ts @@ -8,7 +8,11 @@ import { AST, NestedCollectionAST, FieldAST } from '../types/ast'; import database from '../database'; import * as FieldsService from '../services/fields'; -export default async function getAST(collection: string, query: Query): Promise { +export default async function getASTFromQuery( + role: string | null, + collection: string, + query: Query +): Promise { const ast: AST = { type: 'collection', name: collection, @@ -16,13 +20,14 @@ export default async function getAST(collection: string, query: Query): Promise< children: [], }; - if (!query.fields) query.fields = ['*']; + const fields = query.fields || ['*']; - /** @todo support wildcard */ - const fields = 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 = query.fields.some((field) => field.includes('.')); + const hasRelations = fields.some((field) => field.includes('.')); + if (hasRelations === false) { fields.forEach((field) => { ast.children.push({ @@ -34,12 +39,18 @@ export default async function getAST(collection: string, query: Query): Promise< return ast; } - // Even though we might not need all records from relations, it'll be faster to load all records - // into memory once and search through it in JS than it would be to do individual queries to fetch - // this data field by field + // 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'); - ast.children = await parseFields(collection, query.fields); + // 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); return ast; @@ -48,9 +59,9 @@ export default async function getAST(collection: string, query: Query): Promise< const relationalStructure: Record = {}; - // Swap *.* case for *,.* - for (let i = 0; i < fields.length; i++) { - const fieldKey = fields[i]; + // Swap *.* case for *,.*,.* + for (let index = 0; index < fields.length; index++) { + const fieldKey = fields[index]; if (fieldKey.includes('.') === false) continue; @@ -58,13 +69,23 @@ export default async function getAST(collection: string, query: Query): Promise< if (parts[0] === '*') { const availableFields = await FieldsService.fieldsInCollection(parentCollection); - fields.splice( - i, - 1, - ...availableFields - .filter((field) => !!getRelation(parentCollection, field)) - .map((field) => `${field}.${parts.slice(1).join('.')}`) + + 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('*'); } }