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 @@
-
+
{{ $t('admins_have_all_permissions') }}
+
{{ $tc('field', 0) }}
+
+
+
+
{{ $t('the_following_are_minimum_permissions') }}
+
{{ appMinimal }}
+
@@ -30,6 +36,10 @@ export default defineComponent({
type: Object as PropType,
default: null,
},
+ appMinimal: {
+ type: Object as PropType>,
+ default: undefined,
+ },
},
setup(props, { emit }) {
const fieldsStore = useFieldsStore();
@@ -92,4 +102,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/components/permissions.vue b/app/src/modules/settings/routes/roles/permissions-detail/components/permissions.vue
index f473b64a51..195a2fa583 100644
--- a/app/src/modules/settings/routes/roles/permissions-detail/components/permissions.vue
+++ b/app/src/modules/settings/routes/roles/permissions-detail/components/permissions.vue
@@ -10,6 +10,12 @@
+
+
+
+
{{ $t('the_following_are_minimum_permissions') }}
+
{{ appMinimal }}
+
@@ -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 @@
@@ -24,10 +44,10 @@