@directus/api 21.0.0-rc.0 → 22.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/cache.d.ts +0 -1
  3. package/dist/cache.js +7 -22
  4. package/dist/controllers/tus.js +7 -5
  5. package/dist/database/get-ast-from-query/lib/parse-fields.d.ts +1 -1
  6. package/dist/database/get-ast-from-query/lib/parse-fields.js +10 -0
  7. package/dist/database/helpers/schema/dialects/cockroachdb.d.ts +2 -1
  8. package/dist/database/helpers/schema/dialects/cockroachdb.js +4 -0
  9. package/dist/database/helpers/schema/dialects/mssql.d.ts +2 -1
  10. package/dist/database/helpers/schema/dialects/mssql.js +4 -0
  11. package/dist/database/helpers/schema/dialects/oracle.d.ts +2 -1
  12. package/dist/database/helpers/schema/dialects/oracle.js +4 -0
  13. package/dist/database/helpers/schema/dialects/postgres.d.ts +2 -1
  14. package/dist/database/helpers/schema/dialects/postgres.js +4 -0
  15. package/dist/database/helpers/schema/types.d.ts +5 -0
  16. package/dist/database/helpers/schema/types.js +3 -0
  17. package/dist/database/helpers/schema/utils/preprocess-bindings.d.ts +8 -0
  18. package/dist/database/helpers/schema/utils/preprocess-bindings.js +30 -0
  19. package/dist/database/index.js +14 -6
  20. package/dist/database/migrations/20240305A-change-useragent-type.js +1 -1
  21. package/dist/database/migrations/20240716A-update-files-date-fields.js +33 -0
  22. package/dist/database/migrations/20240806A-permissions-policies.d.ts +6 -0
  23. package/dist/database/migrations/20240806A-permissions-policies.js +338 -0
  24. package/dist/database/run-ast/lib/get-db-query.js +12 -2
  25. package/dist/database/run-ast/utils/apply-case-when.js +5 -4
  26. package/dist/database/run-ast/utils/with-preprocess-bindings.d.ts +2 -0
  27. package/dist/database/run-ast/utils/with-preprocess-bindings.js +14 -0
  28. package/dist/logger/index.js +1 -1
  29. package/dist/middleware/error-handler.d.ts +2 -2
  30. package/dist/middleware/error-handler.js +54 -51
  31. package/dist/permissions/lib/fetch-permissions.d.ts +1 -0
  32. package/dist/permissions/lib/fetch-permissions.js +3 -2
  33. package/dist/permissions/lib/fetch-policies.d.ts +7 -0
  34. package/dist/permissions/lib/fetch-policies.js +16 -1
  35. package/dist/permissions/modules/process-ast/lib/inject-cases.js +6 -6
  36. package/dist/permissions/modules/process-ast/types.d.ts +0 -6
  37. package/dist/permissions/modules/process-ast/utils/extract-paths-from-query.js +11 -1
  38. package/dist/permissions/utils/filter-policies-by-ip.d.ts +1 -1
  39. package/dist/services/assets.js +2 -5
  40. package/dist/services/fields.d.ts +3 -0
  41. package/dist/services/fields.js +29 -5
  42. package/dist/services/files/lib/get-sharp-instance.d.ts +2 -0
  43. package/dist/services/files/lib/get-sharp-instance.js +10 -0
  44. package/dist/services/files/utils/get-metadata.js +7 -6
  45. package/dist/services/files.js +5 -0
  46. package/dist/services/import-export.d.ts +3 -1
  47. package/dist/services/import-export.js +49 -5
  48. package/dist/services/mail/index.d.ts +1 -1
  49. package/dist/services/mail/index.js +9 -1
  50. package/dist/services/relations.d.ts +3 -1
  51. package/dist/services/relations.js +27 -5
  52. package/dist/services/tus/data-store.js +4 -5
  53. package/dist/services/tus/server.d.ts +1 -1
  54. package/dist/services/tus/server.js +9 -2
  55. package/dist/utils/apply-query.d.ts +8 -5
  56. package/dist/utils/apply-query.js +40 -5
  57. package/dist/utils/fetch-user-count/fetch-access-lookup.d.ts +2 -0
  58. package/dist/utils/fetch-user-count/fetch-access-lookup.js +3 -2
  59. package/dist/utils/fetch-user-count/fetch-user-count.js +10 -3
  60. package/dist/utils/fetch-user-count/get-user-count-query.js +1 -1
  61. package/dist/utils/get-schema.js +3 -3
  62. package/dist/utils/sanitize-schema.d.ts +1 -1
  63. package/package.json +38 -38
  64. package/dist/database/migrations/20240710A-permissions-policies.js +0 -169
  65. /package/dist/database/migrations/{20240710A-permissions-policies.d.ts → 20240716A-update-files-date-fields.d.ts} +0 -0
@@ -0,0 +1,338 @@
1
+ import { processChunk, toBoolean } from '@directus/utils';
2
+ import { flatten, intersection, isEqual, merge, omit, uniq } from 'lodash-es';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { fetchPermissions } from '../../permissions/lib/fetch-permissions.js';
5
+ import { fetchPolicies } from '../../permissions/lib/fetch-policies.js';
6
+ import { fetchRolesTree } from '../../permissions/lib/fetch-roles-tree.js';
7
+ import { getSchema } from '../../utils/get-schema.js';
8
+ // Adapted from https://github.com/directus/directus/blob/141b8adbf4dd8e06530a7929f34e3fc68a522053/api/src/utils/merge-permissions.ts#L4
9
+ export function mergePermissions(strategy, ...permissions) {
10
+ const allPermissions = flatten(permissions);
11
+ const mergedPermissions = allPermissions
12
+ .reduce((acc, val) => {
13
+ const key = `${val.collection}__${val.action}`;
14
+ const current = acc.get(key);
15
+ acc.set(key, current ? mergePermission(strategy, current, val) : val);
16
+ return acc;
17
+ }, new Map())
18
+ .values();
19
+ return Array.from(mergedPermissions);
20
+ }
21
+ export function mergePermission(strategy, currentPerm, newPerm) {
22
+ const logicalKey = `_${strategy}`;
23
+ let { permissions, validation, fields, presets } = currentPerm;
24
+ if (newPerm.permissions) {
25
+ if (currentPerm.permissions && Object.keys(currentPerm.permissions)[0] === logicalKey) {
26
+ permissions = {
27
+ [logicalKey]: [
28
+ ...currentPerm.permissions[logicalKey],
29
+ newPerm.permissions,
30
+ ],
31
+ };
32
+ }
33
+ else if (currentPerm.permissions) {
34
+ // Empty {} supersedes other permissions in _OR merge
35
+ if (strategy === 'or' && (isEqual(currentPerm.permissions, {}) || isEqual(newPerm.permissions, {}))) {
36
+ permissions = {};
37
+ }
38
+ else {
39
+ permissions = {
40
+ [logicalKey]: [currentPerm.permissions, newPerm.permissions],
41
+ };
42
+ }
43
+ }
44
+ else {
45
+ permissions = {
46
+ [logicalKey]: [newPerm.permissions],
47
+ };
48
+ }
49
+ }
50
+ if (newPerm.validation) {
51
+ if (currentPerm.validation && Object.keys(currentPerm.validation)[0] === logicalKey) {
52
+ validation = {
53
+ [logicalKey]: [
54
+ ...currentPerm.validation[logicalKey],
55
+ newPerm.validation,
56
+ ],
57
+ };
58
+ }
59
+ else if (currentPerm.validation) {
60
+ // Empty {} supersedes other validations in _OR merge
61
+ if (strategy === 'or' && (isEqual(currentPerm.validation, {}) || isEqual(newPerm.validation, {}))) {
62
+ validation = {};
63
+ }
64
+ else {
65
+ validation = {
66
+ [logicalKey]: [currentPerm.validation, newPerm.validation],
67
+ };
68
+ }
69
+ }
70
+ else {
71
+ validation = {
72
+ [logicalKey]: [newPerm.validation],
73
+ };
74
+ }
75
+ }
76
+ if (newPerm.fields) {
77
+ if (Array.isArray(currentPerm.fields) && strategy === 'or') {
78
+ fields = uniq([...currentPerm.fields, ...newPerm.fields]);
79
+ }
80
+ else if (Array.isArray(currentPerm.fields) && strategy === 'and') {
81
+ fields = intersection(currentPerm.fields, newPerm.fields);
82
+ }
83
+ else {
84
+ fields = newPerm.fields;
85
+ }
86
+ if (fields.includes('*'))
87
+ fields = ['*'];
88
+ }
89
+ if (newPerm.presets) {
90
+ presets = merge({}, presets, newPerm.presets);
91
+ }
92
+ return omit({
93
+ ...currentPerm,
94
+ permissions,
95
+ validation,
96
+ fields,
97
+ presets,
98
+ }, ['id', 'system']);
99
+ }
100
+ async function fetchRoleAccess(roles, context) {
101
+ const roleAccess = {
102
+ admin_access: false,
103
+ app_access: false,
104
+ ip_access: null,
105
+ enforce_tfa: false,
106
+ };
107
+ const accessRows = await context
108
+ .knex('directus_access')
109
+ .select('directus_policies.id', 'directus_policies.admin_access', 'directus_policies.app_access', 'directus_policies.ip_access', 'directus_policies.enforce_tfa')
110
+ .where('role', 'in', roles)
111
+ .leftJoin('directus_policies', 'directus_policies.id', 'directus_access.policy');
112
+ const ipAccess = new Set();
113
+ for (const { admin_access, app_access, ip_access, enforce_tfa } of accessRows) {
114
+ roleAccess.admin_access ||= toBoolean(admin_access);
115
+ roleAccess.app_access ||= toBoolean(app_access);
116
+ roleAccess.enforce_tfa ||= toBoolean(enforce_tfa);
117
+ if (ip_access && ip_access.length) {
118
+ ip_access.split(',').forEach((ip) => ipAccess.add(ip));
119
+ }
120
+ }
121
+ if (ipAccess.size > 0) {
122
+ roleAccess.ip_access = Array.from(ipAccess).join(',');
123
+ }
124
+ return roleAccess;
125
+ }
126
+ /**
127
+ * The public role used to be `null`, we gotta create a single new policy for the permissions
128
+ * previously attached to the public role (marked through `role = null`).
129
+ */
130
+ const PUBLIC_POLICY_ID = 'abf8a154-5b1c-4a46-ac9c-7300570f4f17';
131
+ export async function up(knex) {
132
+ /////////////////////////////////////////////////////////////////////////////////////////////////
133
+ // If the policies table already exists the migration has already run
134
+ if (await knex.schema.hasTable('directus_policies')) {
135
+ return;
136
+ }
137
+ /////////////////////////////////////////////////////////////////////////////////////////////////
138
+ // Create new policies table that mirrors previous Roles
139
+ await knex.schema.createTable('directus_policies', (table) => {
140
+ table.uuid('id').primary();
141
+ table.string('name', 100).notNullable();
142
+ table.string('icon', 64).notNullable().defaultTo('badge');
143
+ table.text('description');
144
+ table.text('ip_access');
145
+ table.boolean('enforce_tfa').defaultTo(false).notNullable();
146
+ table.boolean('admin_access').defaultTo(false).notNullable();
147
+ table.boolean('app_access').defaultTo(false).notNullable();
148
+ });
149
+ /////////////////////////////////////////////////////////////////////////////////////////////////
150
+ // Copy over all existing roles into new policies
151
+ const roles = await knex
152
+ .select('id', 'name', 'icon', 'description', 'ip_access', 'enforce_tfa', 'admin_access', 'app_access')
153
+ .from('directus_roles');
154
+ if (roles.length > 0) {
155
+ await processChunk(roles, 100, async (chunk) => {
156
+ await knex('directus_policies').insert(chunk);
157
+ });
158
+ }
159
+ await knex
160
+ .insert({
161
+ id: PUBLIC_POLICY_ID,
162
+ name: '$t:public_label',
163
+ icon: 'public',
164
+ description: '$t:public_description',
165
+ app_access: false,
166
+ })
167
+ .into('directus_policies');
168
+ // Change the admin policy description to $t:admin_policy_description
169
+ await knex('directus_policies')
170
+ .update({
171
+ description: '$t:admin_policy_description',
172
+ })
173
+ .where('description', 'LIKE', '$t:admin_description');
174
+ /////////////////////////////////////////////////////////////////////////////////////////////////
175
+ // Remove access control + add nesting to roles
176
+ await knex.schema.alterTable('directus_roles', (table) => {
177
+ table.dropColumn('ip_access');
178
+ table.dropColumn('enforce_tfa');
179
+ table.dropColumn('admin_access');
180
+ table.dropColumn('app_access');
181
+ table.uuid('parent').references('directus_roles.id');
182
+ });
183
+ /////////////////////////////////////////////////////////////////////////////////////////////////
184
+ // Link permissions to policies instead of roles
185
+ await knex.schema.alterTable('directus_permissions', (table) => {
186
+ table.uuid('policy').references('directus_policies.id').onDelete('CASCADE');
187
+ // Drop the foreign key constraint here in order to update `null` role to public policy ID
188
+ table.dropForeign('role');
189
+ });
190
+ await knex('directus_permissions')
191
+ .update({
192
+ role: PUBLIC_POLICY_ID,
193
+ })
194
+ .whereNull('role');
195
+ await knex('directus_permissions').update({
196
+ policy: knex.ref('role'),
197
+ });
198
+ await knex.schema.alterTable('directus_permissions', (table) => {
199
+ table.dropColumns('role');
200
+ table.dropNullable('policy');
201
+ });
202
+ /////////////////////////////////////////////////////////////////////////////////////////////////
203
+ // Setup junction table between roles/users and policies
204
+ // This could be a A2O style setup with a collection/item field rather than individual foreign
205
+ // keys, but we want to be able to show the reverse-relationship on the individual policies as
206
+ // well, which would require the O2A type to exist in Directus which currently doesn't.
207
+ // Shouldn't be the end of the world here, as we know we're only attaching policies to two other
208
+ // collections.
209
+ await knex.schema.createTable('directus_access', (table) => {
210
+ table.uuid('id').primary();
211
+ table.uuid('role').references('directus_roles.id').nullable().onDelete('CASCADE');
212
+ table.uuid('user').references('directus_users.id').nullable().onDelete('CASCADE');
213
+ table.uuid('policy').references('directus_policies.id').notNullable().onDelete('CASCADE');
214
+ table.integer('sort');
215
+ });
216
+ /////////////////////////////////////////////////////////////////////////////////////////////////
217
+ // Attach policies to existing roles for backwards compatibility
218
+ const policyAttachments = roles.map((role) => ({
219
+ id: randomUUID(),
220
+ role: role.id,
221
+ user: null,
222
+ policy: role.id,
223
+ sort: 1,
224
+ }));
225
+ await processChunk(policyAttachments, 100, async (chunk) => {
226
+ await knex('directus_access').insert(chunk);
227
+ });
228
+ await knex('directus_access').insert({
229
+ id: randomUUID(),
230
+ role: null,
231
+ user: null,
232
+ policy: PUBLIC_POLICY_ID,
233
+ sort: 1,
234
+ });
235
+ }
236
+ export async function down(knex) {
237
+ /////////////////////////////////////////////////////////////////////////////////////////////////
238
+ // Reinstate access control fields on directus roles
239
+ await knex.schema.alterTable('directus_roles', (table) => {
240
+ table.text('ip_access');
241
+ table.boolean('enforce_tfa').defaultTo(false).notNullable();
242
+ table.boolean('admin_access').defaultTo(false).notNullable();
243
+ table.boolean('app_access').defaultTo(true).notNullable();
244
+ });
245
+ /////////////////////////////////////////////////////////////////////////////////////////////////
246
+ // Copy policy access control rules back to roles
247
+ const originalPermissions = await knex
248
+ .select('id')
249
+ .from('directus_permissions')
250
+ .whereNot({ policy: PUBLIC_POLICY_ID });
251
+ await knex.schema.alterTable('directus_permissions', (table) => {
252
+ table.uuid('role').nullable();
253
+ table.setNullable('policy');
254
+ });
255
+ const context = { knex, schema: await getSchema() };
256
+ // fetch all roles
257
+ const roles = await knex.select('id').from('directus_roles');
258
+ // simulate Public Role
259
+ roles.push({ id: null });
260
+ // role permissions to be inserted once all processing is completed
261
+ const rolePermissions = [];
262
+ for (const role of roles) {
263
+ const roleTree = await fetchRolesTree(role.id, knex);
264
+ let roleAccess = null;
265
+ if (role.id !== null) {
266
+ roleAccess = await fetchRoleAccess(roleTree, context);
267
+ await knex('directus_roles').update(roleAccess).where({ id: role.id });
268
+ }
269
+ if (roleAccess === null || !roleAccess.admin_access) {
270
+ // fetch all of the roles policies
271
+ const policies = await fetchPolicies({ roles: roleTree, user: null, ip: null }, context);
272
+ // fetch all of the policies permissions
273
+ const rawPermissions = await fetchPermissions({
274
+ accountability: { role: null, roles: roleTree, user: null, app: roleAccess?.app_access || false },
275
+ policies,
276
+ bypassDynamicVariableProcessing: true,
277
+ }, context);
278
+ // merge all permissions to single version (v10) and save for later use
279
+ mergePermissions('or', rawPermissions).forEach((permission) => {
280
+ // System permissions are automatically populated
281
+ if (permission.system) {
282
+ return;
283
+ }
284
+ // convert merged permissions to storage ready format
285
+ if (Array.isArray(permission.fields)) {
286
+ permission.fields = permission.fields.join(',');
287
+ }
288
+ if (permission.permissions) {
289
+ permission.permissions = JSON.stringify(permission.permissions);
290
+ }
291
+ if (permission.validation) {
292
+ permission.validation = JSON.stringify(permission.validation);
293
+ }
294
+ if (permission.presets) {
295
+ permission.presets = JSON.stringify(permission.presets);
296
+ }
297
+ rolePermissions.push({ role: role.id, ...omit(permission, ['id', 'policy']) });
298
+ });
299
+ }
300
+ }
301
+ /////////////////////////////////////////////////////////////////////////////////////////////////
302
+ // Remove role nesting support
303
+ await knex.schema.alterTable('directus_roles', (table) => {
304
+ table.dropForeign('parent');
305
+ table.dropColumn('parent');
306
+ });
307
+ /////////////////////////////////////////////////////////////////////////////////////////////////
308
+ // Drop all permissions that are only attached to a user
309
+ // TODO query all policies that are attached to a user and delete their permissions,
310
+ // since we don't know were to put them now and it'll cause a foreign key problem
311
+ // as soon as we reference directus_roles in directus_permissions again
312
+ /////////////////////////////////////////////////////////////////////////////////////////////////
313
+ // Drop policy attachments
314
+ await knex.schema.dropTable('directus_access');
315
+ /////////////////////////////////////////////////////////////////////////////////////////////////
316
+ // Reattach permissions to roles instead of policies
317
+ await knex('directus_permissions')
318
+ .update({
319
+ role: null,
320
+ })
321
+ .where({ role: PUBLIC_POLICY_ID });
322
+ // remove all v11 permissions
323
+ await processChunk(originalPermissions, 100, async (chunk) => {
324
+ await knex('directus_permissions').delete(chunk);
325
+ });
326
+ // insert all v10 permissions
327
+ await processChunk(rolePermissions, 100, async (chunk) => {
328
+ await knex('directus_permissions').insert(chunk);
329
+ });
330
+ await knex.schema.alterTable('directus_permissions', (table) => {
331
+ table.uuid('role').references('directus_roles.id').alter();
332
+ table.dropForeign('policy');
333
+ table.dropColumn('policy');
334
+ });
335
+ /////////////////////////////////////////////////////////////////////////////////////////////////
336
+ // Drop policies table
337
+ await knex.schema.dropTable('directus_policies');
338
+ }
@@ -8,6 +8,7 @@ import { applyCaseWhen } from '../utils/apply-case-when.js';
8
8
  import { getColumnPreprocessor } from '../utils/get-column-pre-processor.js';
9
9
  import { getNodeAlias } from '../utils/get-field-alias.js';
10
10
  import { getInnerQueryColumnPreProcessor } from '../utils/get-inner-query-column-pre-processor.js';
11
+ import { withPreprocessBindings } from '../utils/with-preprocess-bindings.js';
11
12
  export function getDBQuery(schema, knex, table, fieldNodes, o2mNodes, query, cases) {
12
13
  const aliasMap = Object.create(null);
13
14
  const env = useEnv();
@@ -19,8 +20,15 @@ export function getDBQuery(schema, knex, table, fieldNodes, o2mNodes, query, cas
19
20
  queryCopy.limit = typeof queryCopy.limit === 'number' ? queryCopy.limit : Number(env['QUERY_LIMIT_DEFAULT']);
20
21
  // Queries with aggregates and groupBy will not have duplicate result
21
22
  if (queryCopy.aggregate || queryCopy.group) {
22
- const flatQuery = knex.from(table).select(fieldNodes.map((node) => preProcess(node)));
23
- return applyQuery(knex, table, flatQuery, queryCopy, schema, cases).query;
23
+ const flatQuery = knex.from(table);
24
+ // Map the group fields to their respective field nodes
25
+ const groupWhenCases = hasCaseWhen
26
+ ? queryCopy.group?.map((field) => fieldNodes.find(({ fieldKey }) => fieldKey === field)?.whenCase ?? [])
27
+ : undefined;
28
+ const dbQuery = applyQuery(knex, table, flatQuery, queryCopy, schema, cases, { aliasMap, groupWhenCases }).query;
29
+ flatQuery.select(fieldNodes.map((node) => preProcess(node)));
30
+ withPreprocessBindings(knex, dbQuery);
31
+ return dbQuery;
24
32
  }
25
33
  const primaryKey = schema.collections[table].primary;
26
34
  let dbQuery = knex.from(table);
@@ -132,6 +140,8 @@ export function getDBQuery(schema, knex, table, fieldNodes, o2mNodes, query, cas
132
140
  SELECT DISTINCT ...,
133
141
  CASE WHEN <condition> THEN <actual-column> END AS <alias>
134
142
 
143
+ a group-by query is generated.
144
+
135
145
  Another problem is that all not all rows with the same primary key are guaranteed to have the same value for
136
146
  the columns with the case/when, so we to `or` those together, but counting the number of flags in a group by
137
147
  operation. This way the flag is set to > 0 if any of the rows in the group allows access to the column.
@@ -17,10 +17,11 @@ export function applyCaseWhen({ columnCases, table, aliasMap, cases, column, ali
17
17
  }
18
18
  }
19
19
  const sql = sqlParts.join(' ');
20
- const bindings = caseQuery.toSQL().bindings;
21
- const result = knex.raw(`(CASE WHEN ${sql} THEN ?? END)`, [...bindings, column]);
20
+ const bindings = [...caseQuery.toSQL().bindings, column];
21
+ let rawCase = `(CASE WHEN ${sql} THEN ?? END)`;
22
22
  if (alias) {
23
- return knex.raw(result + ' AS ??', [alias]);
23
+ rawCase += ' AS ??';
24
+ bindings.push(alias);
24
25
  }
25
- return result;
26
+ return knex.raw(rawCase, bindings);
26
27
  }
@@ -0,0 +1,2 @@
1
+ import type { Knex } from 'knex';
2
+ export declare function withPreprocessBindings(knex: Knex, dbQuery: Knex.QueryBuilder): void;
@@ -0,0 +1,14 @@
1
+ import { getHelpers } from '../../helpers/index.js';
2
+ export function withPreprocessBindings(knex, dbQuery) {
3
+ const schemaHelper = getHelpers(knex).schema;
4
+ dbQuery.client = new Proxy(dbQuery.client, {
5
+ get(target, prop, receiver) {
6
+ if (prop === 'query') {
7
+ return (connection, queryParam) => {
8
+ return Reflect.get(target, prop, receiver).bind(target)(connection, schemaHelper.preprocessBindings(queryParam));
9
+ };
10
+ }
11
+ return Reflect.get(target, prop, receiver);
12
+ },
13
+ });
14
+ }
@@ -79,7 +79,7 @@ export const createExpressLogger = () => {
79
79
  }
80
80
  if (env['LOG_STYLE'] === 'raw') {
81
81
  httpLoggerOptions.redact = {
82
- paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers'],
82
+ paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers', 'req.query.access_token'],
83
83
  censor: (value, pathParts) => {
84
84
  const path = pathParts.join('.');
85
85
  if (path === 'res.headers') {
@@ -1,3 +1,3 @@
1
+ /// <reference types="qs" />
1
2
  import type { ErrorRequestHandler } from 'express';
2
- declare const errorHandler: ErrorRequestHandler;
3
- export default errorHandler;
3
+ export declare const errorHandler: (err: any, req: import("express-serve-static-core").Request<import("express-serve-static-core").ParamsDictionary, any, any, import("qs").ParsedQs, Record<string, any>>, res: import("express-serve-static-core").Response<any, Record<string, any>, number>, next: import("express-serve-static-core").NextFunction) => Promise<ReturnType<ErrorRequestHandler>>;
@@ -1,40 +1,38 @@
1
- import { ErrorCode, MethodNotAllowedError, isDirectusError } from '@directus/errors';
2
- import { isObject, toArray } from '@directus/utils';
1
+ import { ErrorCode, InternalServerError, isDirectusError } from '@directus/errors';
2
+ import { isObject } from '@directus/utils';
3
3
  import { getNodeEnv } from '@directus/utils/node';
4
4
  import getDatabase from '../database/index.js';
5
5
  import emitter from '../emitter.js';
6
6
  import { useLogger } from '../logger/index.js';
7
- // Note: keep all 4 parameters here. That's how Express recognizes it's the error handler, even if
8
- // we don't use next
9
- const errorHandler = (err, req, res, _next) => {
7
+ const FALLBACK_ERROR = new InternalServerError();
8
+ export const errorHandler = asyncErrorHandler(async (err, req, res) => {
10
9
  const logger = useLogger();
11
- let payload = {
12
- errors: [],
13
- };
14
- const errors = toArray(err);
10
+ let errors = [];
15
11
  let status = null;
16
- for (const error of errors) {
17
- if (getNodeEnv() === 'development') {
18
- if (isObject(error)) {
19
- error['extensions'] = {
20
- ...(error['extensions'] || {}),
21
- stack: error['stack'],
22
- };
23
- }
12
+ // It can be assumed that at least one error is given
13
+ const receivedErrors = Array.isArray(err) ? err : [err];
14
+ for (const error of receivedErrors) {
15
+ // In dev mode, if available, expose stack trace under error's extensions data
16
+ if (getNodeEnv() === 'development' && error instanceof Error && error.stack) {
17
+ (error.extensions ??= {})['stack'] = error.stack;
24
18
  }
25
19
  if (isDirectusError(error)) {
26
20
  logger.debug(error);
27
- if (!status) {
21
+ if (status === null) {
22
+ // Use current error status as response status
28
23
  status = error.status;
29
24
  }
30
25
  else if (status !== error.status) {
31
- status = 500;
26
+ // Fallback if status has already been set by a preceding error
27
+ // and doesn't match the current one
28
+ status = FALLBACK_ERROR.status;
32
29
  }
33
- payload.errors.push({
30
+ errors.push({
34
31
  message: error.message,
35
32
  extensions: {
36
- code: error.code,
37
33
  ...(error.extensions ?? {}),
34
+ // Expose error code under error's extensions data
35
+ code: error.code,
38
36
  },
39
37
  });
40
38
  if (isDirectusError(error, ErrorCode.MethodNotAllowed)) {
@@ -43,45 +41,50 @@ const errorHandler = (err, req, res, _next) => {
43
41
  }
44
42
  else {
45
43
  logger.error(error);
46
- status = 500;
44
+ status = FALLBACK_ERROR.status;
47
45
  if (req.accountability?.admin === true) {
48
46
  const localError = isObject(error) ? error : {};
49
- const message = localError['message'] ?? typeof error === 'string' ? error : null;
50
- payload = {
51
- errors: [
52
- {
53
- message: message || 'An unexpected error occurred.',
54
- extensions: {
55
- code: 'INTERNAL_SERVER_ERROR',
56
- ...(localError['extensions'] ?? {}),
57
- },
47
+ // Use 'message' prop if available, otherwise if 'error' is a string use that
48
+ const message = (typeof localError['message'] === 'string' ? localError['message'] : null) ??
49
+ (typeof error === 'string' ? error : null);
50
+ errors = [
51
+ {
52
+ message: message || FALLBACK_ERROR.message,
53
+ extensions: {
54
+ code: FALLBACK_ERROR.code,
55
+ ...(localError['extensions'] ?? {}),
58
56
  },
59
- ],
60
- };
57
+ },
58
+ ];
61
59
  }
62
60
  else {
63
- payload = {
64
- errors: [
65
- {
66
- message: 'An unexpected error occurred.',
67
- extensions: {
68
- code: 'INTERNAL_SERVER_ERROR',
69
- },
70
- },
71
- ],
72
- };
61
+ // Don't expose unknown errors to non-admin users
62
+ errors = [{ message: FALLBACK_ERROR.message, extensions: { code: FALLBACK_ERROR.code } }];
73
63
  }
74
64
  }
75
65
  }
76
- res.status(status ?? 500);
77
- emitter
78
- .emitFilter('request.error', payload.errors, {}, {
66
+ res.status(status ?? FALLBACK_ERROR.status);
67
+ const updatedErrors = await emitter.emitFilter('request.error', errors, {}, {
79
68
  database: getDatabase(),
80
69
  schema: req.schema,
81
70
  accountability: req.accountability ?? null,
82
- })
83
- .then((updatedErrors) => {
84
- return res.json({ ...payload, errors: updatedErrors });
85
71
  });
86
- };
87
- export default errorHandler;
72
+ return res.json({ errors: updatedErrors });
73
+ });
74
+ function asyncErrorHandler(fn) {
75
+ return (err, req, res, next) => fn(err, req, res, next).catch((error) => {
76
+ // To be on the safe side and ensure this doesn't lead to an unhandled (potentially crashing) error
77
+ try {
78
+ const logger = useLogger();
79
+ logger.error(error, 'Unexpected error in error handler');
80
+ }
81
+ catch {
82
+ // Ignore
83
+ }
84
+ // Delegate to default error handler to close the connection
85
+ if (res.headersSent)
86
+ return next(err);
87
+ res.status(FALLBACK_ERROR.status);
88
+ return res.json({ errors: [{ message: FALLBACK_ERROR.message, extensions: { code: FALLBACK_ERROR.code } }] });
89
+ });
90
+ }
@@ -6,5 +6,6 @@ export interface FetchPermissionsOptions {
6
6
  policies: string[];
7
7
  collections?: string[];
8
8
  accountability?: Pick<Accountability, 'user' | 'role' | 'roles' | 'app'>;
9
+ bypassDynamicVariableProcessing?: boolean;
9
10
  }
10
11
  export declare function _fetchPermissions(options: FetchPermissionsOptions, context: Context): Promise<Permission[]>;
@@ -3,11 +3,12 @@ import { fetchDynamicVariableContext } from '../utils/fetch-dynamic-variable-con
3
3
  import { processPermissions } from '../utils/process-permissions.js';
4
4
  import { withCache } from '../utils/with-cache.js';
5
5
  import { withAppMinimalPermissions } from './with-app-minimal-permissions.js';
6
- export const fetchPermissions = withCache('permissions', _fetchPermissions, ({ action, policies, collections, accountability }) => ({
6
+ export const fetchPermissions = withCache('permissions', _fetchPermissions, ({ action, policies, collections, accountability, bypassDynamicVariableProcessing }) => ({
7
7
  policies, // we assume that policies always come from the same source, so they should be in the same order
8
8
  ...(action && { action }),
9
9
  ...(collections && { collections: sortBy(collections) }),
10
10
  ...(accountability && { accountability: pick(accountability, ['user', 'role', 'roles', 'app']) }),
11
+ ...(bypassDynamicVariableProcessing && { bypassDynamicVariableProcessing }),
11
12
  }));
12
13
  export async function _fetchPermissions(options, context) {
13
14
  const { PermissionsService } = await import('../../services/permissions.js');
@@ -29,7 +30,7 @@ export async function _fetchPermissions(options, context) {
29
30
  // This ensures that if a sorted array of policies is passed in the permissions are returned in the same order
30
31
  // which is necessary for correctly applying the presets in order
31
32
  permissions = sortBy(permissions, (permission) => options.policies.indexOf(permission.policy));
32
- if (options.accountability) {
33
+ if (options.accountability && !options.bypassDynamicVariableProcessing) {
33
34
  // Add app minimal permissions for the request accountability, if applicable.
34
35
  // Normally this is done in the permissions service readByQuery, but it also needs to do it here
35
36
  // since the permissions service is created without accountability.
@@ -1,5 +1,12 @@
1
1
  import type { Accountability } from '@directus/types';
2
2
  import type { Context } from '../types.js';
3
+ export interface AccessRow {
4
+ policy: {
5
+ id: string;
6
+ ip_access: string[] | null;
7
+ };
8
+ role: string | null;
9
+ }
3
10
  export declare const fetchPolicies: typeof _fetchPolicies;
4
11
  /**
5
12
  * Fetch the policies associated with the current user accountability
@@ -19,10 +19,25 @@ export async function _fetchPolicies({ roles, user, ip }, context) {
19
19
  const filter = user ? { _or: [{ user: { _eq: user } }, roleFilter] } : roleFilter;
20
20
  const accessRows = (await accessService.readByQuery({
21
21
  filter,
22
- fields: ['policy.id', 'policy.ip_access'],
22
+ fields: ['policy.id', 'policy.ip_access', 'role'],
23
23
  limit: -1,
24
24
  }));
25
25
  const filteredAccessRows = filterPoliciesByIp(accessRows, ip);
26
+ /*
27
+ * Sort rows by priority (goes bottom up):
28
+ * - Parent role policies
29
+ * - Child role policies
30
+ * - User policies
31
+ */
32
+ filteredAccessRows.sort((a, b) => {
33
+ if (!a.role && !b.role)
34
+ return 0;
35
+ if (!a.role)
36
+ return 1;
37
+ if (!b.role)
38
+ return -1;
39
+ return roles.indexOf(a.role) - roles.indexOf(b.role);
40
+ });
26
41
  const ids = filteredAccessRows.map(({ policy }) => policy.id);
27
42
  return ids;
28
43
  }