From b7d87e581aed26ee0176f2cbaf5b10a490e89831 Mon Sep 17 00:00:00 2001 From: Rijk van Zanten Date: Thu, 11 Feb 2021 12:50:56 -0500 Subject: [PATCH] System permissions for app access (#4004) * Pass relations through schema, instead of individual reads * Fetch field transforms upfront * Fix length check * List if user has app access or not in accountability * Load permissions up front, merge app access minimal permissions * Show app access required permissions in permissions overview * Show system minimal permissions in permissions detail * Fix app access check in authenticate for jwt use * Fix minimal permissions for presets * Remove /permissions/me in favor of root use w/ permissions * Fix logical nested OR in an AND * Use root permissions endpoint with filter instead of /me * Allow filter query on /permissions * Add system minimal app access permissions into result of /permissions * Remove stray console log * Remove stray console.dir * Set current role as role for minimal permissions * Fix no-permissions state for user detail * Add filter items function that allows altering existing result set --- api/src/cli/commands/bootstrap/index.ts | 3 +- api/src/cli/commands/roles/create.ts | 6 +- api/src/cli/commands/users/create.ts | 4 +- api/src/controllers/fields.ts | 2 +- api/src/controllers/permissions.ts | 34 +---- api/src/database/index.ts | 1 - api/src/database/run-ast.ts | 6 +- .../app-access-permissions.yaml | 96 +++++++++++++ .../app-access-permissions/index.ts | 17 +++ api/src/middleware/authenticate.ts | 7 +- api/src/middleware/collection-exists.ts | 2 +- api/src/middleware/schema.ts | 19 +-- api/src/services/authorization.ts | 39 ++---- api/src/services/collections.ts | 30 ++-- api/src/services/fields.ts | 53 ++++--- api/src/services/items.ts | 22 +-- api/src/services/meta.ts | 12 +- api/src/services/payload.ts | 56 ++++---- api/src/services/permissions.ts | 72 ++++++++-- api/src/services/relations.ts | 11 +- api/src/services/specifications.ts | 10 +- api/src/services/utils.ts | 16 +-- api/src/types/accountability.ts | 1 + api/src/types/express.d.ts | 5 +- api/src/types/permissions.ts | 5 +- api/src/types/schema.ts | 14 +- api/src/utils/apply-query.ts | 27 +++- api/src/utils/filter-items.ts | 38 ++++++ api/src/utils/get-ast-from-query.ts | 31 ++--- api/src/utils/get-default-value.ts | 2 +- api/src/utils/get-local-type.ts | 2 +- api/src/utils/get-schema.ts | 69 ++++++++++ api/src/utils/merge-permissions.ts | 88 ++++++++++++ app/package.json | 1 + .../interfaces/wysiwyg/get-editor-styles.ts | 2 +- app/src/lang/translations/en-US.yaml | 11 +- app/src/modules/collections/routes/item.vue | 41 +++--- app/src/modules/files/routes/item.vue | 51 +++---- .../modules/settings/routes/roles/add-new.vue | 4 +- .../roles/app-recommended-permissions.ts | 56 ++++++++ .../routes/roles/app-required-permissions.ts | 129 ------------------ .../components/permissions-overview-row.vue | 11 +- .../permissions-overview-toggle.vue | 53 ++++++- .../item/components/permissions-overview.vue | 60 ++++---- .../settings/routes/roles/item/item.vue | 11 +- .../permissions-detail/components/fields.vue | 27 ++++ .../components/permissions.vue | 27 ++++ .../permissions-detail/permissions-detail.vue | 50 +++++-- .../settings/routes/roles/public-item.vue | 3 +- app/src/modules/users/index.ts | 1 + app/src/modules/users/routes/item.vue | 60 ++++---- app/src/stores/permissions.ts | 6 +- app/src/styles/_base.scss | 3 +- app/src/styles/mixins/type-styles.scss | 3 - .../components/image-editor/image-editor.vue | 11 +- 55 files changed, 897 insertions(+), 524 deletions(-) create mode 100644 api/src/database/system-data/app-access-permissions/app-access-permissions.yaml create mode 100644 api/src/database/system-data/app-access-permissions/index.ts create mode 100644 api/src/utils/filter-items.ts create mode 100644 api/src/utils/get-schema.ts create mode 100644 api/src/utils/merge-permissions.ts create mode 100644 app/src/modules/settings/routes/roles/app-recommended-permissions.ts delete mode 100644 app/src/modules/settings/routes/roles/app-required-permissions.ts diff --git a/api/src/cli/commands/bootstrap/index.ts b/api/src/cli/commands/bootstrap/index.ts index 5a17882a3f..1e6512d88f 100644 --- a/api/src/cli/commands/bootstrap/index.ts +++ b/api/src/cli/commands/bootstrap/index.ts @@ -2,6 +2,7 @@ import env from '../../../env'; import logger from '../../../logger'; import installDatabase from '../../../database/seeds/run'; import runMigrations from '../../../database/migrations/run'; +import { getSchema } from '../../../utils/get-schema'; import { nanoid } from 'nanoid'; export default async function bootstrap() { @@ -22,7 +23,7 @@ export default async function bootstrap() { await installDatabase(database); - const schema = await schemaInspector.overview(); + const schema = await getSchema(); logger.info('Setting up first admin role...'); const rolesService = new RolesService({ schema }); diff --git a/api/src/cli/commands/roles/create.ts b/api/src/cli/commands/roles/create.ts index 6e696cfcb6..2a0aeae731 100644 --- a/api/src/cli/commands/roles/create.ts +++ b/api/src/cli/commands/roles/create.ts @@ -1,5 +1,7 @@ +import { getSchema } from '../../../utils/get-schema'; + export default async function rolesCreate({ name, admin }: any) { - const { default: database, schemaInspector } = require('../../../database/index'); + const { default: database } = require('../../../database/index'); const { RolesService } = require('../../../services/roles'); if (!name) { @@ -8,7 +10,7 @@ export default async function rolesCreate({ name, admin }: any) { } try { - const schema = await schemaInspector.overview(); + const schema = await getSchema(); const service = new RolesService({ schema: schema, knex: database }); const id = await service.create({ name, admin_access: admin }); diff --git a/api/src/cli/commands/users/create.ts b/api/src/cli/commands/users/create.ts index 50b450a096..77f3ae3650 100644 --- a/api/src/cli/commands/users/create.ts +++ b/api/src/cli/commands/users/create.ts @@ -1,3 +1,5 @@ +import { getSchema } from '../../../utils/get-schema'; + export default async function usersCreate({ email, password, role }: any) { const { default: database, schemaInspector } = require('../../../database/index'); const { UsersService } = require('../../../services/users'); @@ -8,7 +10,7 @@ export default async function usersCreate({ email, password, role }: any) { } try { - const schema = await schemaInspector.overview(); + const schema = await getSchema(); const service = new UsersService({ schema, knex: database }); const id = await service.create({ email, password, role, status: 'active' }); diff --git a/api/src/controllers/fields.ts b/api/src/controllers/fields.ts index 0d581af1f6..01d8e400cd 100644 --- a/api/src/controllers/fields.ts +++ b/api/src/controllers/fields.ts @@ -52,7 +52,7 @@ router.get( schema: req.schema, }); - if (req.params.field in req.schema[req.params.collection].columns === false) throw new ForbiddenException(); + if (req.params.field in req.schema.tables[req.params.collection].columns === false) throw new ForbiddenException(); const field = await service.readOne(req.params.collection, req.params.field); diff --git a/api/src/controllers/permissions.ts b/api/src/controllers/permissions.ts index 61e39cbdb9..0952c692fe 100644 --- a/api/src/controllers/permissions.ts +++ b/api/src/controllers/permissions.ts @@ -1,8 +1,7 @@ import express from 'express'; import asyncHandler from '../utils/async-handler'; import { PermissionsService, MetaService } from '../services'; -import { clone } from 'lodash'; -import { InvalidCredentialsException, ForbiddenException, InvalidPayloadException } from '../exceptions'; +import { ForbiddenException, InvalidPayloadException } from '../exceptions'; import useCollection from '../middleware/use-collection'; import { respond } from '../middleware/respond'; import { PrimaryKey } from '../types'; @@ -42,6 +41,7 @@ router.get( accountability: req.accountability, schema: req.schema, }); + const metaService = new MetaService({ accountability: req.accountability, schema: req.schema, @@ -56,36 +56,6 @@ router.get( respond ); -router.get( - '/me', - asyncHandler(async (req, res, next) => { - const service = new PermissionsService({ schema: req.schema }); - const query = clone(req.sanitizedQuery || {}); - - if (req.accountability?.role) { - query.filter = { - ...(query.filter || {}), - role: { - _eq: req.accountability.role, - }, - }; - } else { - query.filter = { - ...(query.filter || {}), - role: { - _null: true, - }, - }; - } - - const items = await service.readByQuery(query); - - res.locals.payload = { data: items || null }; - return next(); - }), - respond -); - router.get( '/:pk', asyncHandler(async (req, res, next) => { diff --git a/api/src/database/index.ts b/api/src/database/index.ts index 8508a779b1..48670d91e9 100644 --- a/api/src/database/index.ts +++ b/api/src/database/index.ts @@ -1,6 +1,5 @@ import knex, { Config } from 'knex'; import dotenv from 'dotenv'; -import camelCase from 'camelcase'; import path from 'path'; import logger from '../logger'; import env from '../env'; diff --git a/api/src/database/run-ast.ts b/api/src/database/run-ast.ts index bf8f1d4c77..6cacf1b8ca 100644 --- a/api/src/database/run-ast.ts +++ b/api/src/database/run-ast.ts @@ -93,8 +93,8 @@ async function parseCurrentLevel( children: (NestedCollectionNode | FieldNode)[], schema: SchemaOverview ) { - const primaryKeyField = schema[collection].primary; - const columnsInCollection = Object.keys(schema[collection].columns); + const primaryKeyField = schema.tables[collection].primary; + const columnsInCollection = Object.keys(schema.tables[collection].columns); const columnsToSelect: string[] = []; const nestedCollectionNodes: NestedCollectionNode[] = []; @@ -154,7 +154,7 @@ async function getDBQuery( query.sort = query.sort || [{ column: primaryKeyField, order: 'asc' }]; - await applyQuery(knex, table, dbQuery, queryCopy, schema); + await applyQuery(table, dbQuery, queryCopy, schema); // Nested filters use joins to filter on the parent level, to prevent duplicate // parents, we group the query by the current tables primary key (which is unique) diff --git a/api/src/database/system-data/app-access-permissions/app-access-permissions.yaml b/api/src/database/system-data/app-access-permissions/app-access-permissions.yaml new file mode 100644 index 0000000000..942f4d6df6 --- /dev/null +++ b/api/src/database/system-data/app-access-permissions/app-access-permissions.yaml @@ -0,0 +1,96 @@ +# NOTE: Activity/collections/fields/presets/relations/revisions will have an extra hardcoded filter +# to filter out collections you don't have read access + +- collection: directus_activity + action: read + permissions: + user: + _eq: $CURRENT_USER + +- collection: directus_activity + action: create + validation: + comment: + _nnull: true + +- collection: directus_collections + action: read + +- collection: directus_fields + action: read + +- collection: directus_permissions + action: read + permissions: + role: + _eq: $CURRENT_ROLE + +- collection: directus_presets + action: read + permissions: + _or: + - user: + _eq: $CURRENT_USER + - _and: + - user: + _null: true + - role: + _eq: $CURRENT_ROLE + - _and: + - user: + _null: true + - role: + _null: true + +- collection: directus_presets + action: create + validation: + - user: + _eq: $CURRENT_USER + +- collection: directus_presets + action: update + permissions: + user: + _eq: $CURRENT_USER + +- collection: directus_presets + action: delete + permissions: + user: + _eq: $CURRENT_USER + +- collection: directus_relations + action: read + +- collection: directus_roles + action: read + permissions: + id: + _eq: $CURRENT_ROLE + +- collection: directus_settings + action: read + +- collection: directus_users + action: read + permissions: + id: + _eq: $CURRENT_USER + fields: + - id + - first_name + - last_name + - email + - password + - location + - title + - description + - tags + - preferences_divider + - avatar + - language + - theme + - tfa_secret + - status + - role diff --git a/api/src/database/system-data/app-access-permissions/index.ts b/api/src/database/system-data/app-access-permissions/index.ts new file mode 100644 index 0000000000..e14871b34e --- /dev/null +++ b/api/src/database/system-data/app-access-permissions/index.ts @@ -0,0 +1,17 @@ +import { requireYAML } from '../../../utils/require-yaml'; +import { Permission } from '../../../types'; +import { merge } from 'lodash'; + +const defaults: Partial = { + role: null, + permissions: {}, + validation: null, + presets: null, + fields: ['*'], + limit: null, + system: true, +}; + +const permissions = requireYAML(require.resolve('./app-access-permissions.yaml')) as Permission[]; + +export const appAccessMinimalPermissions: Permission[] = permissions.map((row) => merge({}, defaults, row)); diff --git a/api/src/middleware/authenticate.ts b/api/src/middleware/authenticate.ts index 20cf9d4092..bac4bd915a 100644 --- a/api/src/middleware/authenticate.ts +++ b/api/src/middleware/authenticate.ts @@ -14,6 +14,7 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => { user: null, role: null, admin: false, + app: false, ip: req.ip.startsWith('::ffff:') ? req.ip.substring(7) : req.ip, userAgent: req.get('user-agent'), }; @@ -36,7 +37,7 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => { } const user = await database - .select('role', 'directus_roles.admin_access') + .select('role', 'directus_roles.admin_access', 'directus_roles.app_access') .from('directus_users') .leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id') .where({ @@ -52,10 +53,11 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => { req.accountability.user = payload.id; req.accountability.role = user.role; req.accountability.admin = user.admin_access === true || user.admin_access == 1; + req.accountability.app = user.app_access === true || user.app_access == 1; } else { // Try finding the user with the provided token const user = await database - .select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access') + .select('directus_users.id', 'directus_users.role', 'directus_roles.admin_access', 'directus_roles.app_access') .from('directus_users') .leftJoin('directus_roles', 'directus_users.role', 'directus_roles.id') .where({ @@ -71,6 +73,7 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => { req.accountability.user = user.id; req.accountability.role = user.role; req.accountability.admin = user.admin_access === true || user.admin_access == 1; + req.accountability.app = user.app_access === true || user.app_access == 1; } if (req.accountability?.user) { diff --git a/api/src/middleware/collection-exists.ts b/api/src/middleware/collection-exists.ts index 41f3454d5f..5a63321a80 100644 --- a/api/src/middleware/collection-exists.ts +++ b/api/src/middleware/collection-exists.ts @@ -11,7 +11,7 @@ import { systemCollectionRows } from '../database/system-data/collections'; const collectionExists: RequestHandler = asyncHandler(async (req, res, next) => { if (!req.params.collection) return next(); - if (req.params.collection in req.schema === false) { + if (req.params.collection in req.schema.tables === false) { throw new ForbiddenException(); } diff --git a/api/src/middleware/schema.ts b/api/src/middleware/schema.ts index f51c90d728..d418ef910a 100644 --- a/api/src/middleware/schema.ts +++ b/api/src/middleware/schema.ts @@ -1,21 +1,10 @@ import { RequestHandler } from 'express'; import asyncHandler from '../utils/async-handler'; -import { schemaInspector } from '../database'; -import logger from '../logger'; - -const getSchema: RequestHandler = asyncHandler(async (req, res, next) => { - const schemaOverview = await schemaInspector.overview(); - - for (const [collection, info] of Object.entries(schemaOverview)) { - if (!info.primary) { - logger.warn(`Collection "${collection}" doesn't have a primary key column and will be ignored`); - delete schemaOverview[collection]; - } - } - - req.schema = schemaOverview; +import { getSchema } from '../utils/get-schema'; +const schema: RequestHandler = asyncHandler(async (req, res, next) => { + req.schema = await getSchema(req.accountability); return next(); }); -export default getSchema; +export default schema; diff --git a/api/src/services/authorization.ts b/api/src/services/authorization.ts index 0b203bfc53..210d00a690 100644 --- a/api/src/services/authorization.ts +++ b/api/src/services/authorization.ts @@ -41,19 +41,12 @@ export class AuthorizationService { async processAST(ast: AST, action: PermissionsAction = 'read'): Promise { const collectionsRequested = getCollectionsFromAST(ast); - let permissionsForCollections = await this.knex - .select('*') - .from('directus_permissions') - .where({ action, role: this.accountability?.role }) - .whereIn( - 'collection', - collectionsRequested.map(({ collection }) => collection) + let permissionsForCollections = this.schema.permissions.filter((permission) => { + return ( + permission.action === action && + collectionsRequested.map(({ collection }) => collection).includes(permission.collection) ); - - permissionsForCollections = (await this.payloadService.processValues( - 'read', - permissionsForCollections - )) as Permission[]; + }); // 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; @@ -203,17 +196,13 @@ export class AuthorizationService { presets: {}, }; } else { - permission = await this.knex - .select('*') - .from('directus_permissions') - .where({ action, collection, role: this.accountability?.role || null }) - .first(); + permission = this.schema.permissions.find((permission) => { + return permission.action === action; + }); if (!permission) throw new ForbiddenException(); - permission = (await this.payloadService.processValues('read', permission as Item)) as Permission; - - // Check if you have permission to access the fields you're trying to acces + // Check if you have permission to access the fields you're trying to access const allowedFields = permission.fields || []; @@ -235,22 +224,18 @@ export class AuthorizationService { payloads = payloads.map((payload) => merge({}, preset, payload)); - const columns = Object.values(this.schema[collection].columns); + const columns = Object.values(this.schema.tables[collection].columns); let requiredColumns: string[] = []; for (const column of columns) { const field = - (await this.knex - .select<{ special: string }>('special') - .from('directus_fields') - .where({ collection, field: column.column_name }) - .first()) || + this.schema.fields.find((field) => field.collection === collection && field.field === column.column_name) || systemFieldRows.find( (fieldMeta) => fieldMeta.field === column.column_name && fieldMeta.collection === collection ); - const specials = field?.special ? toArray(field.special) : []; + const specials = field?.special ?? []; const hasGenerateSpecial = ['uuid', 'date-created', 'role-created', 'user-created'].some((name) => specials.includes(name) diff --git a/api/src/services/collections.ts b/api/src/services/collections.ts index d1736c924c..21aadd7fa4 100644 --- a/api/src/services/collections.ts +++ b/api/src/services/collections.ts @@ -71,7 +71,7 @@ export class CollectionsService { throw new InvalidPayloadException(`Collections can't start with "directus_"`); } - if (payload.collection in this.schema) { + if (payload.collection in this.schema.tables) { throw new InvalidPayloadException(`Collection "${payload.collection}" already exists.`); } @@ -113,12 +113,9 @@ export class CollectionsService { const collectionKeys = toArray(collection); if (this.accountability && this.accountability.admin !== true) { - const permissions = await this.knex - .select('collection') - .from('directus_permissions') - .where({ action: 'read' }) - .where({ role: this.accountability.role }) - .whereIn('collection', collectionKeys); + const permissions = this.schema.permissions.filter((permission) => { + return permission.action === 'read' && collectionKeys.includes(permission.collection); + }); if (collectionKeys.length !== permissions.length) { const collectionsYouHavePermissionToRead = permissions.map(({ collection }) => collection); @@ -163,12 +160,11 @@ export class CollectionsService { 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, - action: 'read', + const collectionsYouHavePermissionToRead: string[] = this.schema.permissions + .filter((permission) => { + return permission.action === 'read'; }) - ).map(({ collection }) => collection); + .map(({ collection }) => collection); tablesInDatabase = tablesInDatabase.filter((table) => { return collectionsYouHavePermissionToRead.includes(table.name); @@ -272,7 +268,7 @@ export class CollectionsService { schema: this.schema, }); - const tablesInDatabase = Object.keys(this.schema); + const tablesInDatabase = Object.keys(this.schema.tables); const collectionKeys = toArray(collection); @@ -290,11 +286,9 @@ export class CollectionsService { await this.knex('directus_activity').delete().whereIn('collection', collectionKeys); await this.knex('directus_permissions').delete().whereIn('collection', collectionKeys); - const relations = await this.knex - .select('*') - .from('directus_relations') - .where({ many_collection: collection }) - .orWhere({ one_collection: collection }); + const relations = this.schema.relations.filter((relation) => { + return relation.many_collection === collection || relation.one_collection === collection; + }); for (const relation of relations) { const isM2O = relation.many_collection === collection; diff --git a/api/src/services/fields.ts b/api/src/services/fields.ts index 18e4cee1ba..9993c9bc3a 100644 --- a/api/src/services/fields.ts +++ b/api/src/services/fields.ts @@ -120,14 +120,14 @@ export class FieldsService { // Filter the result so we only return the fields you have read access to if (this.accountability && this.accountability.admin !== true) { - const permissions = await this.knex - .select('collection', 'fields') - .from('directus_permissions') - .where({ role: this.accountability.role, action: 'read' }); + const permissions = this.schema.permissions.filter((permission) => { + return permission.action === 'read'; + }); + const allowedFieldsInCollection: Record = {}; permissions.forEach((permission) => { - allowedFieldsInCollection[permission.collection] = (permission.fields || '').split(','); + allowedFieldsInCollection[permission.collection] = permission.fields ?? []; }); if (collection && allowedFieldsInCollection.hasOwnProperty(collection) === false) { @@ -147,19 +147,13 @@ export class FieldsService { async readOne(collection: string, field: string) { if (this.accountability && this.accountability.admin !== true) { - const permissions = await this.knex - .select('fields') - .from('directus_permissions') - .where({ - role: this.accountability.role, - collection, - action: 'read', - }) - .first(); + const permissions = this.schema.permissions.find((permission) => { + return permission.action === 'read' && permission.collection === collection; + }); - if (!permissions) throw new ForbiddenException(); - if (permissions.fields !== '*') { - const allowedFields = (permissions.fields || '').split(','); + if (!permissions || !permissions.fields) throw new ForbiddenException(); + if (permissions.fields.includes('*') === false) { + const allowedFields = permissions.fields; if (allowedFields.includes(field) === false) throw new ForbiddenException(); } } @@ -201,10 +195,10 @@ export class FieldsService { } // Check if field already exists, either as a column, or as a row in directus_fields - if (field.field in this.schema[collection].columns) { + if (field.field in this.schema.tables[collection].columns) { throw new InvalidPayloadException(`Field "${field.field}" already exists in collection "${collection}"`); } else if ( - !!(await this.knex.select('id').from('directus_fields').where({ collection, field: field.field }).first()) + !!this.schema.fields.find((fieldMeta) => fieldMeta.collection === collection && fieldMeta.field === field.field) ) { throw new InvalidPayloadException(`Field "${field.field}" already exists in collection "${collection}"`); } @@ -245,11 +239,9 @@ export class FieldsService { } if (field.meta) { - const record = await database - .select<{ id: number }>('id') - .from('directus_fields') - .where({ collection, field: field.field }) - .first(); + const record = this.schema.fields.find( + (fieldMeta) => fieldMeta.field === field.field && fieldMeta.collection === collection + ); if (record) { await this.itemsService.update( @@ -284,17 +276,18 @@ export class FieldsService { await this.knex('directus_fields').delete().where({ collection, field }); - if (field in this.schema[collection].columns) { + if (field in this.schema.tables[collection].columns) { await this.knex.schema.table(collection, (table) => { table.dropColumn(field); }); } - const relations = await this.knex - .select('*') - .from('directus_relations') - .where({ many_collection: collection, many_field: field }) - .orWhere({ one_collection: collection, one_field: field }); + const relations = this.schema.relations.filter((relation) => { + return ( + (relation.many_collection === collection && relation.many_field === field) || + (relation.one_collection === collection && relation.one_field === field) + ); + }); for (const relation of relations) { const isM2O = relation.many_collection === collection && relation.many_field === field; diff --git a/api/src/services/items.ts b/api/src/services/items.ts index bae310931a..91fe1fbb5d 100644 --- a/api/src/services/items.ts +++ b/api/src/services/items.ts @@ -46,8 +46,8 @@ export class ItemsService implements AbstractSer async create(data: Partial[]): Promise; async create(data: Partial): Promise; async create(data: Partial | Partial[]): Promise { - const primaryKeyField = this.schema[this.collection].primary; - const columns = Object.keys(this.schema[this.collection].columns); + const primaryKeyField = this.schema.tables[this.collection].primary; + const columns = Object.keys(this.schema.tables[this.collection].columns); let payloads: AnyItem[] = clone(toArray(data)); @@ -210,7 +210,7 @@ export class ItemsService implements AbstractSer action: PermissionsAction = 'read' ): Promise | Partial[]> { query = clone(query); - const primaryKeyField = this.schema[this.collection].primary; + const primaryKeyField = this.schema.tables[this.collection].primary; const keys = toArray(key); if (keys.length === 1) { @@ -257,8 +257,8 @@ export class ItemsService implements AbstractSer data: Partial | Partial[], key?: PrimaryKey | PrimaryKey[] ): Promise { - const primaryKeyField = this.schema[this.collection].primary; - const columns = Object.keys(this.schema[this.collection].columns); + const primaryKeyField = this.schema.tables[this.collection].primary; + const columns = Object.keys(this.schema.tables[this.collection].columns); // Updating one or more items to the same payload if (data && key) { @@ -401,7 +401,7 @@ export class ItemsService implements AbstractSer } async updateByQuery(data: Partial, query: Query): Promise { - const primaryKeyField = this.schema[this.collection].primary; + const primaryKeyField = this.schema.tables[this.collection].primary; const readQuery = cloneDeep(query); readQuery.fields = [primaryKeyField]; @@ -422,7 +422,7 @@ export class ItemsService implements AbstractSer upsert(data: Partial[]): Promise; upsert(data: Partial): Promise; async upsert(data: Partial | Partial[]): Promise { - const primaryKeyField = this.schema[this.collection].primary; + const primaryKeyField = this.schema.tables[this.collection].primary; const payloads = toArray(data); const primaryKeys: PrimaryKey[] = []; @@ -452,7 +452,7 @@ export class ItemsService implements AbstractSer delete(keys: PrimaryKey[]): Promise; async delete(key: PrimaryKey | PrimaryKey[]): Promise { const keys = toArray(key); - const primaryKeyField = this.schema[this.collection].primary; + const primaryKeyField = this.schema.tables[this.collection].primary; if (this.accountability && this.accountability.admin !== true) { const authorizationService = new AuthorizationService({ @@ -508,7 +508,7 @@ export class ItemsService implements AbstractSer } async deleteByQuery(query: Query): Promise { - const primaryKeyField = this.schema[this.collection].primary; + const primaryKeyField = this.schema.tables[this.collection].primary; const readQuery = cloneDeep(query); readQuery.fields = [primaryKeyField]; @@ -532,7 +532,7 @@ export class ItemsService implements AbstractSer const record = (await this.readByQuery(query, opts)) as Partial; if (!record) { - let columns = Object.values(this.schema[this.collection].columns); + let columns = Object.values(this.schema.tables[this.collection].columns); const defaults: Record = {}; if (query.fields && query.fields.includes('*') === false) { @@ -552,7 +552,7 @@ export class ItemsService implements AbstractSer } async upsertSingleton(data: Partial) { - const primaryKeyField = this.schema[this.collection].primary; + const primaryKeyField = this.schema.tables[this.collection].primary; const record = await this.knex.select(primaryKeyField).from(this.collection).limit(1).first(); diff --git a/api/src/services/meta.ts b/api/src/services/meta.ts index f7a26a9f8e..60ebbc2f79 100644 --- a/api/src/services/meta.ts +++ b/api/src/services/meta.ts @@ -1,16 +1,18 @@ import { Query } from '../types/query'; import database from '../database'; -import { AbstractServiceOptions, Accountability } from '../types'; +import { AbstractServiceOptions, Accountability, SchemaOverview } from '../types'; import Knex from 'knex'; import { applyFilter } from '../utils/apply-query'; export class MetaService { knex: Knex; accountability: Accountability | null; + schema: SchemaOverview; - constructor(options?: AbstractServiceOptions) { - this.knex = options?.knex || database; - this.accountability = options?.accountability || null; + constructor(options: AbstractServiceOptions) { + this.knex = options.knex || database; + this.accountability = options.accountability || null; + this.schema = options.schema; } async getMetaForQuery(collection: string, query: Query) { @@ -40,7 +42,7 @@ export class MetaService { const dbQuery = this.knex(collection).count('*', { as: 'count' }); if (query.filter) { - await applyFilter(this.knex, dbQuery, query.filter, collection); + await applyFilter(this.schema, dbQuery, query.filter, collection); } const records = await dbQuery; diff --git a/api/src/services/payload.ts b/api/src/services/payload.ts index 23838f4837..18ee61ebcb 100644 --- a/api/src/services/payload.ts +++ b/api/src/services/payload.ts @@ -7,16 +7,13 @@ import argon2 from 'argon2'; import { v4 as uuidv4 } from 'uuid'; import database from '../database'; import { clone, isObject, cloneDeep } from 'lodash'; -import { Relation, Item, AbstractServiceOptions, Accountability, PrimaryKey, SchemaOverview } from '../types'; +import { Item, AbstractServiceOptions, Accountability, PrimaryKey, SchemaOverview } from '../types'; import { ItemsService } from './items'; -import { URL } from 'url'; import Knex from 'knex'; -import env from '../env'; import getLocalType from '../utils/get-local-type'; import { format, formatISO } from 'date-fns'; import { ForbiddenException } from '../exceptions'; import { toArray } from '../utils/to-array'; -import { FieldMeta } from '../types'; import { systemFieldRows } from '../database/system-data/fields'; import { systemRelationRows } from '../database/system-data/relations'; import { InvalidPayloadException } from '../exceptions'; @@ -141,13 +138,20 @@ export class PayloadService { const fieldsInPayload = Object.keys(processedPayload[0]); - let specialFieldsInCollection: FieldMeta[] = await this.knex - .select('field', 'special') - .from('directus_fields') - .where({ collection: this.collection }) - .whereNotNull('special'); + let specialFieldsInCollection = this.schema.fields.filter( + (field) => field.collection === this.collection && field.special && field.special.length > 0 + ); - specialFieldsInCollection.push(...systemFieldRows.filter((fieldMeta) => fieldMeta.collection === this.collection)); + specialFieldsInCollection.push( + ...systemFieldRows + .filter((fieldMeta) => fieldMeta.collection === this.collection) + .map((fieldMeta) => ({ + id: fieldMeta.id, + collection: fieldMeta.collection, + field: fieldMeta.field, + special: fieldMeta.special ?? [], + })) + ); if (action === 'read') { specialFieldsInCollection = specialFieldsInCollection.filter((fieldMeta) => { @@ -187,7 +191,12 @@ export class PayloadService { return processedPayload[0]; } - async processField(field: FieldMeta, payload: Partial, action: Action, accountability: Accountability | null) { + async processField( + field: SchemaOverview['fields'][number], + payload: Partial, + action: Action, + accountability: Accountability | null + ) { if (!field.special) return payload[field.field]; const fieldSpecials = field.special ? toArray(field.special) : []; @@ -212,7 +221,7 @@ export class PayloadService { * shouldn't return with time / timezone info respectively */ async processDates(payloads: Partial>[]) { - const columnsInCollection = Object.values(this.schema[this.collection].columns); + const columnsInCollection = Object.values(this.schema.tables[this.collection].columns); const columnsWithType = columnsInCollection.map((column) => ({ name: column.column_name, @@ -265,10 +274,9 @@ export class PayloadService { processA2O(payloads: Partial): Promise>; async processA2O(payload: Partial | Partial[]): Promise | Partial[]> { const relations = [ - ...(await this.knex - .select('*') - .from('directus_relations') - .where({ many_collection: this.collection })), + ...this.schema.relations.filter((relation) => { + return relation.many_collection === this.collection; + }), ...systemRelationRows.filter((systemRelation) => systemRelation.many_collection === this.collection), ]; @@ -309,7 +317,7 @@ export class PayloadService { schema: this.schema, }); - const relatedPrimary = this.schema[relatedCollection].primary; + const relatedPrimary = this.schema.tables[relatedCollection].primary; const relatedRecord: Partial = payload[relation.many_field]; const hasPrimaryKey = relatedRecord.hasOwnProperty(relatedPrimary); @@ -337,10 +345,9 @@ export class PayloadService { processM2O(payloads: Partial): Promise>; async processM2O(payload: Partial | Partial[]): Promise | Partial[]> { const relations = [ - ...(await this.knex - .select('*') - .from('directus_relations') - .where({ many_collection: this.collection })), + ...this.schema.relations.filter((relation) => { + return relation.many_collection === this.collection; + }), ...systemRelationRows.filter((systemRelation) => systemRelation.many_collection === this.collection), ]; @@ -391,10 +398,9 @@ export class PayloadService { */ async processO2M(payload: Partial | Partial[], parent?: PrimaryKey) { const relations = [ - ...(await this.knex - .select('*') - .from('directus_relations') - .where({ one_collection: this.collection })), + ...this.schema.relations.filter((relation) => { + return relation.one_collection === this.collection; + }), ...systemRelationRows.filter((systemRelation) => systemRelation.one_collection === this.collection), ]; diff --git a/api/src/services/permissions.ts b/api/src/services/permissions.ts index 5d243ca5a5..b6962d77fd 100644 --- a/api/src/services/permissions.ts +++ b/api/src/services/permissions.ts @@ -1,34 +1,78 @@ -import { AbstractServiceOptions, PermissionsAction } from '../types'; +import { AbstractServiceOptions, PermissionsAction, Query, Item, PrimaryKey } from '../types'; import { ItemsService } from '../services/items'; +import { filterItems } from '../utils/filter-items'; + +import { appAccessMinimalPermissions } from '../database/system-data/app-access-permissions'; export class PermissionsService extends ItemsService { constructor(options: AbstractServiceOptions) { super('directus_permissions', options); } - async getAllowedCollections(role: string | null, action: PermissionsAction) { - const query = this.knex.select('collection').from('directus_permissions').where({ role, action }); - const results = await query; - return results.map((result) => result.collection); - } + getAllowedFields(action: PermissionsAction, collection?: string) { + const results = this.schema.permissions.filter((permission) => { + let matchesCollection = true; - async getAllowedFields(role: string | null, action: PermissionsAction, collection?: string) { - const query = this.knex.select('collection', 'fields').from('directus_permissions').where({ role, action }); + if (collection) { + matchesCollection = permission.collection === collection; + } - if (collection) { - query.andWhere({ collection }); - } - - const results = await query; + return permission.action === action; + }); const fieldsPerCollection: Record = {}; for (const result of results) { const { collection, fields } = result; if (!fieldsPerCollection[collection]) fieldsPerCollection[collection] = []; - fieldsPerCollection[collection].push(...(fields || '').split(',')); + fieldsPerCollection[collection].push(...(fields ?? [])); } return fieldsPerCollection; } + + async readByQuery( + query: Query, + opts?: { stripNonRequested?: boolean } + ): Promise | Partial[]> { + const result = await super.readByQuery(query, opts); + + if (Array.isArray(result) && this.accountability && this.accountability.app === true) { + result.push( + ...filterItems( + appAccessMinimalPermissions.map((permission) => ({ + ...permission, + role: this.accountability!.role, + })), + query.filter + ) + ); + } + + return result; + } + + readByKey(keys: PrimaryKey[], query?: Query, action?: PermissionsAction): Promise[]>; + readByKey(key: PrimaryKey, query?: Query, action?: PermissionsAction): Promise>; + async readByKey( + key: PrimaryKey | PrimaryKey[], + query: Query = {}, + action: PermissionsAction = 'read' + ): Promise | Partial[]> { + const result = await super.readByKey(key as any, query, action); + + if (Array.isArray(result) && this.accountability && this.accountability.app === true) { + result.push( + ...filterItems( + appAccessMinimalPermissions.map((permission) => ({ + ...permission, + role: this.accountability!.role, + })), + query.filter + ) + ); + } + + return result; + } } diff --git a/api/src/services/relations.ts b/api/src/services/relations.ts index 72dabf9a45..27b24bfeb0 100644 --- a/api/src/services/relations.ts +++ b/api/src/services/relations.ts @@ -61,12 +61,13 @@ export class RelationsService extends ItemsService { if (relations === null) return null; if (this.accountability === null || this.accountability?.admin === true) return relations; - const allowedCollections = await this.permissionsService.getAllowedCollections( - this.accountability?.role || null, - 'read' - ); + const allowedCollections = this.schema.permissions + .filter((permission) => { + return permission.action === 'read'; + }) + .map(({ collection }) => collection); - const allowedFields = await this.permissionsService.getAllowedFields(this.accountability?.role || null, 'read'); + const allowedFields = this.permissionsService.getAllowedFields('read'); relations = toArray(relations); diff --git a/api/src/services/specifications.ts b/api/src/services/specifications.ts index e6146e1405..cb524c35a4 100644 --- a/api/src/services/specifications.ts +++ b/api/src/services/specifications.ts @@ -62,6 +62,7 @@ interface SpecificationSubService { class OASService implements SpecificationSubService { accountability: Accountability | null; knex: Knex; + schema: SchemaOverview; fieldsService: FieldsService; collectionsService: CollectionsService; @@ -81,6 +82,7 @@ class OASService implements SpecificationSubService { ) { this.accountability = options.accountability || null; this.knex = options.knex || database; + this.schema = options.schema; this.fieldsService = fieldsService; this.collectionsService = collectionsService; @@ -91,10 +93,7 @@ class OASService implements SpecificationSubService { const collections = await this.collectionsService.readByQuery(); const fields = await this.fieldsService.readAll(); const relations = (await this.relationsService.readByQuery({})) as Relation[]; - const permissions: Permission[] = await this.knex - .select('*') - .from('directus_permissions') - .where({ role: this.accountability?.role || null }); + const permissions = this.schema.permissions; const tags = await this.generateTags(collections); const paths = await this.generatePaths(permissions, tags); @@ -104,7 +103,8 @@ class OASService implements SpecificationSubService { openapi: '3.0.1', info: { title: 'Dynamic API Specification', - description: 'This is a dynamicly generated API specification for all endpoints existing on the current .', + description: + 'This is a dynamically generated API specification for all endpoints existing on the current project.', version: version, }, servers: [ diff --git a/api/src/services/utils.ts b/api/src/services/utils.ts index e1dc2b8b99..c75d9ebda2 100644 --- a/api/src/services/utils.ts +++ b/api/src/services/utils.ts @@ -27,28 +27,22 @@ export class UtilsService { } if (this.accountability?.admin !== true) { - const permissions = await this.knex - .select('fields') - .from('directus_permissions') - .where({ - collection, - action: 'update', - role: this.accountability?.role || null, - }) - .first(); + const permissions = this.schema.permissions.find((permission) => { + return permission.collection === collection && permission.action === 'update'; + }); if (!permissions) { throw new ForbiddenException(); } - const allowedFields = permissions.fields.split(','); + const allowedFields = permissions.fields ?? []; if (allowedFields[0] !== '*' && allowedFields.includes(sortField) === false) { throw new ForbiddenException(); } } - const primaryKeyField = this.schema[collection].primary; + const primaryKeyField = this.schema.tables[collection].primary; // Make sure all rows have a sort value const countResponse = await this.knex.count('* as count').from(collection).whereNull(sortField).first(); diff --git a/api/src/types/accountability.ts b/api/src/types/accountability.ts index 6f68b487da..19e1dd3f4d 100644 --- a/api/src/types/accountability.ts +++ b/api/src/types/accountability.ts @@ -2,6 +2,7 @@ export type Accountability = { role: string | null; user?: string | null; admin?: boolean; + app?: boolean; ip?: string; userAgent?: string; diff --git a/api/src/types/express.d.ts b/api/src/types/express.d.ts index 2ac1e6a295..fb3896670f 100644 --- a/api/src/types/express.d.ts +++ b/api/src/types/express.d.ts @@ -3,9 +3,11 @@ */ import { Permission } from './permissions'; +import { Relation } from './relation'; + import { Query } from './query'; import { Accountability } from './accountability'; -import { SchemaOverview } from '@directus/schema/dist/types/overview'; +import { SchemaOverview } from './schema'; export {}; @@ -19,7 +21,6 @@ declare global { accountability?: Accountability; singleton?: boolean; - permissions?: Permission; } } } diff --git a/api/src/types/permissions.ts b/api/src/types/permissions.ts index 40f93ac8ad..d4f6b9b840 100644 --- a/api/src/types/permissions.ts +++ b/api/src/types/permissions.ts @@ -1,13 +1,14 @@ export type PermissionsAction = 'create' | 'read' | 'update' | 'delete' | 'comment' | 'explain'; export type Permission = { - id: number; + id?: number; role: string | null; collection: string; action: PermissionsAction; permissions: Record; - validation: Record; + validation: Record | null; limit: number | null; presets: Record | null; fields: string[] | null; + system?: true; }; diff --git a/api/src/types/schema.ts b/api/src/types/schema.ts index 4d62af69e8..695957987a 100644 --- a/api/src/types/schema.ts +++ b/api/src/types/schema.ts @@ -1,3 +1,15 @@ import { SchemaOverview as SO } from '@directus/schema/dist/types/overview'; +import { Relation } from './relation'; +import { Permission } from './permissions'; -export type SchemaOverview = SO; +export type SchemaOverview = { + tables: SO; + relations: Relation[]; + fields: { + id: number; + collection: string; + field: string; + special: string[]; + }[]; + permissions: Permission[]; +}; diff --git a/api/src/utils/apply-query.ts b/api/src/utils/apply-query.ts index ee6cf4467c..766f661b22 100644 --- a/api/src/utils/apply-query.ts +++ b/api/src/utils/apply-query.ts @@ -1,6 +1,5 @@ import { QueryBuilder } from 'knex'; import { Query, Filter, Relation, SchemaOverview } from '../types'; -import Knex from 'knex'; import { clone, isPlainObject } from 'lodash'; import { systemRelationRows } from '../database/system-data/relations'; import { nanoid } from 'nanoid'; @@ -8,14 +7,13 @@ import getLocalType from './get-local-type'; import validate from 'uuid-validate'; export default async function applyQuery( - knex: Knex, collection: string, dbQuery: QueryBuilder, query: Query, schema: SchemaOverview ) { if (query.filter) { - await applyFilter(knex, dbQuery, query.filter, collection); + await applyFilter(schema, dbQuery, query.filter, collection); } if (query.sort) { @@ -39,7 +37,7 @@ export default async function applyQuery( } if (query.search) { - const columns = Object.values(schema[collection].columns); + const columns = Object.values(schema.tables[collection].columns); dbQuery.andWhere(function () { columns @@ -64,8 +62,13 @@ export default async function applyQuery( } } -export async function applyFilter(knex: Knex, rootQuery: QueryBuilder, rootFilter: Filter, collection: string) { - const relations: Relation[] = [...(await knex.select('*').from('directus_relations')), ...systemRelationRows]; +export async function applyFilter( + schema: SchemaOverview, + rootQuery: QueryBuilder, + rootFilter: Filter, + collection: string +) { + const relations: Relation[] = [...schema.relations, ...systemRelationRows]; const aliasMap: Record = {}; @@ -75,6 +78,11 @@ export async function applyFilter(knex: Knex, rootQuery: QueryBuilder, rootFilte function addJoins(dbQuery: QueryBuilder, filter: Filter, collection: string) { for (const [key, value] of Object.entries(filter)) { if (key === '_or' || key === '_and') { + // If the _or array contains an empty object (full permissions), we should short-circuit and ignore all other + // permission checks, as {} already matches full permissions. + if (key === '_or' && value.some((subFilter: Record) => Object.keys(subFilter).length === 0)) + continue; + value.forEach((subFilter: Record) => { addJoins(dbQuery, subFilter, collection); }); @@ -137,8 +145,13 @@ export async function applyFilter(knex: Knex, rootQuery: QueryBuilder, rootFilte function addWhereClauses(dbQuery: QueryBuilder, filter: Filter, collection: string, logical: 'and' | 'or' = 'and') { for (const [key, value] of Object.entries(filter)) { if (key === '_or' || key === '_and') { + // If the _or array contains an empty object (full permissions), we should short-circuit and ignore all other + // permission checks, as {} already matches full permissions. + if (key === '_or' && value.some((subFilter: Record) => Object.keys(subFilter).length === 0)) + continue; + /** @NOTE this callback function isn't called until Knex runs the query */ - dbQuery.where((subQuery) => { + dbQuery[logical].where((subQuery) => { value.forEach((subFilter: Record) => { addWhereClauses(subQuery, subFilter, collection, key === '_and' ? 'and' : 'or'); }); diff --git a/api/src/utils/filter-items.ts b/api/src/utils/filter-items.ts new file mode 100644 index 0000000000..3a8854ffbc --- /dev/null +++ b/api/src/utils/filter-items.ts @@ -0,0 +1,38 @@ +import { Query } from '../types'; +import generateJoi from './generate-joi'; + +/* + Note: Filtering is normally done through SQL in run-ast. This function can be used in case an already + existing array of items has to be filtered using the same filter syntax as used in the ast-to-sql flow + */ + +export function filterItems(items: Record[], filter: Query['filter']) { + if (!filter) return items; + + return items.filter((item) => { + return passesFilter(item, filter); + }); + + function passesFilter(item: Record, filter: Query['filter']): boolean { + if (!filter) return true; + + if (Object.keys(filter)[0] === '_and') { + const subfilter = Object.values(filter)[0] as Query['filter'][]; + + return subfilter.every((subFilter) => { + return passesFilter(item, subFilter); + }); + } else if (Object.keys(filter)[0] === '_or') { + const subfilter = Object.values(filter)[0] as Query['filter'][]; + + return subfilter.some((subFilter) => { + return passesFilter(item, subFilter); + }); + } else { + const schema = generateJoi(filter); + + const { error } = schema.validate(item); + return error === undefined; + } + } +} diff --git a/api/src/utils/get-ast-from-query.ts b/api/src/utils/get-ast-from-query.ts index 74e7168ca8..89101ba9f9 100644 --- a/api/src/utils/get-ast-from-query.ts +++ b/api/src/utils/get-ast-from-query.ts @@ -7,7 +7,6 @@ import { NestedCollectionNode, FieldNode, Query, - Relation, PermissionsAction, Accountability, SchemaOverview, @@ -39,20 +38,14 @@ export default async function getASTFromQuery( const accountability = options?.accountability; const action = options?.action || 'read'; - const knex = options?.knex || database; - /** - * 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 relations = [...(await knex.select('*').from('directus_relations')), ...systemRelationRows]; + const relations = [...schema.relations, ...systemRelationRows]; const permissions = accountability && accountability.admin !== true - ? await knex - .select<{ collection: string; fields: string }[]>('collection', 'fields') - .from('directus_permissions') - .where({ role: accountability.role, action: action }) + ? schema.permissions.filter((permission) => { + return permission.action === action; + }) : null; const ast: AST = { @@ -159,7 +152,7 @@ export default async function getASTFromQuery( children: {}, query: {}, relatedKey: {}, - parentKey: schema[parentCollection].primary, + parentKey: schema.tables[parentCollection].primary, fieldKey: relationalField, relation: relation, }; @@ -171,7 +164,7 @@ export default async function getASTFromQuery( ); child.query[relatedCollection] = {}; - child.relatedKey[relatedCollection] = schema[relatedCollection].primary; + child.relatedKey[relatedCollection] = schema.tables[relatedCollection].primary; } } else if (relatedCollection) { if (permissions && permissions.some((permission) => permission.collection === relatedCollection) === false) { @@ -182,8 +175,8 @@ export default async function getASTFromQuery( type: relationType, name: relatedCollection, fieldKey: relationalField, - parentKey: schema[parentCollection].primary, - relatedKey: schema[relatedCollection].primary, + parentKey: schema.tables[parentCollection].primary, + relatedKey: schema.tables[relatedCollection].primary, relation: relation, query: deep?.[relationalField] || {}, children: await parseFields(relatedCollection, nestedFields as string[]), @@ -206,9 +199,7 @@ export default async function getASTFromQuery( let allowedFields = fieldsInCollection; if (permissions) { - const permittedFields = permissions - .find((permission) => parentCollection === permission.collection) - ?.fields?.split(','); + const permittedFields = permissions.find((permission) => parentCollection === permission.collection)?.fields; if (permittedFields) allowedFields = permittedFields; } @@ -294,9 +285,9 @@ export default async function getASTFromQuery( } async function getFieldsInCollection(collection: string) { - const columns = Object.keys(schema[collection].columns); + const columns = Object.keys(schema.tables[collection].columns); const fields = [ - ...(await knex.select('field').from('directus_fields').where({ collection })).map((field) => field.field), + ...schema.fields.filter((field) => field.collection === collection).map((field) => field.field), ...systemFieldRows.filter((fieldMeta) => fieldMeta.collection === collection).map((fieldMeta) => fieldMeta.field), ]; diff --git a/api/src/utils/get-default-value.ts b/api/src/utils/get-default-value.ts index bad31f4a36..0fcac58b67 100644 --- a/api/src/utils/get-default-value.ts +++ b/api/src/utils/get-default-value.ts @@ -2,7 +2,7 @@ import getLocalType from './get-local-type'; import { Column } from '@directus/schema/dist/types/column'; import { SchemaOverview } from '../types'; -export default function getDefaultValue(column: SchemaOverview[string]['columns'][string] | Column) { +export default function getDefaultValue(column: SchemaOverview['tables'][string]['columns'][string] | Column) { const type = getLocalType(column); let defaultValue = column.default_value || null; diff --git a/api/src/utils/get-local-type.ts b/api/src/utils/get-local-type.ts index 8acfcf3b81..327439d437 100644 --- a/api/src/utils/get-local-type.ts +++ b/api/src/utils/get-local-type.ts @@ -81,7 +81,7 @@ const localTypeMap: Record { + const schemaOverview = await schemaInspector.overview(); + + for (const [collection, info] of Object.entries(schemaOverview)) { + if (!info.primary) { + logger.warn(`Collection "${collection}" doesn't have a primary key column and will be ignored`); + delete schemaOverview[collection]; + } + } + + const relations = await database.select('*').from('directus_relations'); + + const fields = await database + .select<{ id: number; collection: string; field: string; special: string }[]>( + 'id', + 'collection', + 'field', + 'special' + ) + .from('directus_fields'); + + let permissions: Permission[] = []; + + if (accountability && accountability.admin !== true) { + const permissionsForRole = await database + .select('*') + .from('directus_permissions') + .where({ role: accountability.role }); + + permissions = permissionsForRole.map((permissionRaw) => { + if (permissionRaw.permissions && typeof permissionRaw.permissions === 'string') { + permissionRaw.permissions = JSON.parse(permissionRaw.permissions); + } + + if (permissionRaw.validation && typeof permissionRaw.validation === 'string') { + permissionRaw.validation = JSON.parse(permissionRaw.validation); + } + + if (permissionRaw.fields && typeof permissionRaw.fields === 'string') { + permissionRaw.fields = permissionRaw.fields.split(','); + } + + return permissionRaw; + }); + + if (accountability.app === true) { + permissions = mergePermissions( + permissions, + appAccessMinimalPermissions.map((perm) => ({ ...perm, role: accountability.role })) + ); + } + } + + return { + tables: schemaOverview, + relations: relations, + fields: fields.map((transform) => ({ + ...transform, + special: transform.special?.split(','), + })), + permissions: permissions, + }; +} diff --git a/api/src/utils/merge-permissions.ts b/api/src/utils/merge-permissions.ts new file mode 100644 index 0000000000..64c0e0feba --- /dev/null +++ b/api/src/utils/merge-permissions.ts @@ -0,0 +1,88 @@ +import { Permission } from '../types'; +import { merge, omit } from 'lodash'; + +export function mergePermissions(...permissions: Permission[][]): Permission[] { + const allPermissions = permissions.flat(); + + const mergedPermissions = allPermissions + .reduce((acc, val) => { + const key = `${val.collection}__${val.action}__${val.role || '$PUBLIC'}`; + const current = acc.get(key); + acc.set(key, current ? mergePerm(current, val) : val); + return acc; + }, new Map()) + .values(); + + const result = Array.from(mergedPermissions).map((perm) => { + return omit(perm, ['id', 'system']) as Permission; + }); + + return result; +} + +function mergePerm(currentPerm: Permission, newPerm: Permission) { + let permissions = currentPerm.permissions; + let validation = currentPerm.validation; + let fields = currentPerm.fields; + let presets = currentPerm.presets; + let limit = currentPerm.limit; + + if (newPerm.permissions) { + if (currentPerm.permissions && Object.keys(currentPerm.permissions)[0] === '_or') { + permissions = { + _or: [...currentPerm.permissions._or, newPerm.permissions], + }; + } else if (currentPerm.permissions) { + permissions = { + _or: [currentPerm.permissions, newPerm.permissions], + }; + } else { + permissions = { + _or: [newPerm.permissions], + }; + } + } + + if (newPerm.validation) { + if (currentPerm.validation && Object.keys(currentPerm.validation)[0] === '_or') { + validation = { + _or: [...currentPerm.validation._or, newPerm.validation], + }; + } else if (currentPerm.validation) { + validation = { + _or: [currentPerm.validation, newPerm.validation], + }; + } else { + validation = { + _or: [newPerm.validation], + }; + } + } + + if (newPerm.fields) { + if (Array.isArray(currentPerm.fields)) { + fields = [...new Set([...currentPerm.fields, ...newPerm.fields])]; + } else { + fields = newPerm.fields; + } + + if (fields.includes('*')) fields = ['*']; + } + + if (newPerm.presets) { + presets = merge({}, presets, newPerm.presets); + } + + if (newPerm.limit && newPerm.limit > (currentPerm.limit || 0)) { + limit = newPerm.limit; + } + + return { + ...currentPerm, + permissions, + validation, + fields, + presets, + limit, + }; +} diff --git a/app/package.json b/app/package.json index 3b93bf08a8..3d35d7fb1b 100644 --- a/app/package.json +++ b/app/package.json @@ -34,6 +34,7 @@ }, "gitHead": "4476da28dbbc2824e680137aa28b2b91b5afabec", "dependencies": { + "directus": "file:../api", "@directus/docs": "file:../docs", "@directus/format-title": "file:../packages/format-title" }, diff --git a/app/src/interfaces/wysiwyg/get-editor-styles.ts b/app/src/interfaces/wysiwyg/get-editor-styles.ts index ff9ce6d6ca..77812e37f8 100644 --- a/app/src/interfaces/wysiwyg/get-editor-styles.ts +++ b/app/src/interfaces/wysiwyg/get-editor-styles.ts @@ -120,7 +120,7 @@ hr { margin-bottom: 56px; text-align: center; } -hr: after { +hr:after { content: "..."; font-size: 28px; letter-spacing: 16px; diff --git a/app/src/lang/translations/en-US.yaml b/app/src/lang/translations/en-US.yaml index 459e189e43..5ca71c9b51 100644 --- a/app/src/lang/translations/en-US.yaml +++ b/app/src/lang/translations/en-US.yaml @@ -11,6 +11,7 @@ role_name: Role Name db_only_click_to_configure: 'Database Only: Click to Configure ' show_archived_items: Show Archived Items required: Required +required_for_app_access: Required for App Access requires_value: Requires value create_preset: Create Preset create_role: Create Role @@ -52,8 +53,14 @@ archive_confirm: Are you sure you want to archive this item? archive_confirm_count: >- No Items Selected | Are you sure you want to archive this item? | Are you sure you want to archive these {count} items? -reset_system_permissions: Reset System Permissions -reset_system_permissions_copy: Reset all all system permissions to their defaults +reset_system_permissions_to: 'Reset System Permissions to:' +reset_system_permissions_copy: + This action will overwrite any custom permissions you may have applied to the system collections. Are you sure? +the_following_are_minimum_permissions: + The following are minimum permissions required when "App Access" is enabled. You can extend permissions beyond this, + but not below. +app_access_minimum: App Access Minimum +recommended_defaults: Recommended Defaults unarchive: Unarchive unarchive_confirm: Are you sure you want to unarchive this item? nested_files_folders_will_be_moved: Nested files and folders will be moved one level up. diff --git a/app/src/modules/collections/routes/item.vue b/app/src/modules/collections/routes/item.vue index f3dad6abdf..4a8d1ce823 100644 --- a/app/src/modules/collections/routes/item.vue +++ b/app/src/modules/collections/routes/item.vue @@ -124,7 +124,7 @@ rounded icon :loading="saving" - :disabled="isSavable === false" + :disabled="isSavable === false || saveAllowed === false" v-tooltip.bottom="saveAllowed ? $t('save') : $t('not_allowed')" @click="saveAndQuit" > @@ -175,7 +175,7 @@
(); - const userStore = useUserStore(); const permissionsStore = usePermissionsStore(); + const hasRevisionsPermissions = computed(() => { + return !!permissionsStore.state.permissions.find( + (permission) => permission.collection === 'directus_revisions' && permission.action === 'read' + ); + }); + const { collection, primaryKey } = toRefs(props); const { breadcrumb } = useBreadcrumb(); @@ -382,6 +380,7 @@ export default defineComponent({ fields, isSingleton, _primaryKey, + hasRevisionsPermissions, }; function useBreadcrumb() { @@ -488,7 +487,7 @@ export default defineComponent({ diff --git a/app/src/modules/settings/routes/roles/item/components/permissions-overview.vue b/app/src/modules/settings/routes/roles/item/components/permissions-overview.vue index 0855c23a1c..34532a757f 100644 --- a/app/src/modules/settings/routes/roles/item/components/permissions-overview.vue +++ b/app/src/modules/settings/routes/roles/item/components/permissions-overview.vue @@ -31,29 +31,31 @@ :role="role" :permissions="permissions.filter((p) => p.collection === collection.collection)" :refreshing="refreshing" + :app-minimal="appAccess && appMinimalPermissions.filter((p) => p.collection === collection.collection)" />
- + + {{ $t('reset_system_permissions_to') }} + + / + + - + - + - {{ $t('reset_system_permissions') }} - {{ $t('reset_system_permissions_copy') }} + + {{ $t('reset_system_permissions_copy') }} + {{ $t('cancel') }} - {{ $t('reset') }} + + {{ $t('reset') }} + @@ -67,9 +69,11 @@ import PermissionsOverviewHeader from './permissions-overview-header.vue'; import PermissionsOverviewRow from './permissions-overview-row.vue'; import { Permission } from '@/types'; import api from '@/api'; -import { permissions as appRequiredPermissions } from '../../app-required-permissions'; +import { appRecommendedPermissions } from '../../app-recommended-permissions'; import { unexpectedError } from '@/utils/unexpected-error'; +import appMinimalPermissions from 'directus/dist/database/system-data/app-access-permissions/app-access-permissions.yaml'; + export default defineComponent({ components: { PermissionsOverviewHeader, PermissionsOverviewRow }, props: { @@ -91,15 +95,11 @@ export default defineComponent({ const collectionsStore = useCollectionsStore(); const regularCollections = computed(() => - collectionsStore.state.collections.filter( - (collection) => collection.collection.startsWith('directus_') === false - ) + collectionsStore.state.collections.filter((collection) => collection.collection.startsWith('directus_') === false) ); const systemCollections = computed(() => - collectionsStore.state.collections.filter( - (collection) => collection.collection.startsWith('directus_') === true - ) + collectionsStore.state.collections.filter((collection) => collection.collection.startsWith('directus_') === true) ); const systemVisible = ref(false); @@ -125,6 +125,7 @@ export default defineComponent({ resetSystemPermissions, resetting, resetError, + appMinimalPermissions, }; function usePermissions() { @@ -177,13 +178,13 @@ export default defineComponent({ } function useReset() { - const resetActive = ref(false); + const resetActive = ref(false); const resetting = ref(false); const resetError = ref(null); return { resetActive, resetSystemPermissions, resetting, resetError }; - async function resetSystemPermissions() { + async function resetSystemPermissions(useRecommended: boolean) { resetting.value = true; const toBeDeleted = permissions.value @@ -195,10 +196,10 @@ export default defineComponent({ await api.delete(`/permissions/${toBeDeleted.join(',')}`); } - if (props.role !== null && props.appAccess === true) { + if (props.role !== null && props.appAccess === true && useRecommended === true) { await api.post( '/permissions', - appRequiredPermissions.map((permission) => ({ + appRecommendedPermissions.map((permission) => ({ ...permission, role: props.role, })) @@ -255,10 +256,15 @@ export default defineComponent({ display: block; margin: 8px auto; color: var(--foreground-subdued); - transition: color var(--fast) var(--transition); + text-align: center; - &:hover { - color: var(--foreground); + button { + color: var(--primary) !important; + transition: color var(--fast) var(--transition); + } + + button:hover { + color: var(--foreground-normal) !important; } } diff --git a/app/src/modules/settings/routes/roles/item/item.vue b/app/src/modules/settings/routes/roles/item/item.vue index 0b73ebec9b..0549de4ebd 100644 --- a/app/src/modules/settings/routes/roles/item/item.vue +++ b/app/src/modules/settings/routes/roles/item/item.vue @@ -7,11 +7,7 @@ @@ -28,6 +34,10 @@ export default defineComponent({ type: Object as PropType, default: null, }, + appMinimal: { + type: Object as PropType>, + default: undefined, + }, }, setup(props, { emit }) { const _permission = useSync(props, 'permission', emit); @@ -53,4 +63,21 @@ export default defineComponent({ .v-notice { margin-bottom: 36px; } + +.app-minimal { + .v-divider { + margin: 24px 0; + } + + .v-notice { + margin-bottom: 24px; + } + + .app-minimal-preview { + padding: 16px; + font-family: var(--family-monospace); + background-color: var(--background-subdued); + border-radius: var(--border-radius); + } +} diff --git a/app/src/modules/settings/routes/roles/permissions-detail/permissions-detail.vue b/app/src/modules/settings/routes/roles/permissions-detail/permissions-detail.vue index caa1c23d42..77c0f5b774 100644 --- a/app/src/modules/settings/routes/roles/permissions-detail/permissions-detail.vue +++ b/app/src/modules/settings/routes/roles/permissions-detail/permissions-detail.vue @@ -11,10 +11,30 @@
- - - - + + + +