@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.
- package/dist/app.js +1 -1
- package/dist/cache.d.ts +0 -1
- package/dist/cache.js +7 -22
- package/dist/controllers/tus.js +7 -5
- package/dist/database/get-ast-from-query/lib/parse-fields.d.ts +1 -1
- package/dist/database/get-ast-from-query/lib/parse-fields.js +10 -0
- package/dist/database/helpers/schema/dialects/cockroachdb.d.ts +2 -1
- package/dist/database/helpers/schema/dialects/cockroachdb.js +4 -0
- package/dist/database/helpers/schema/dialects/mssql.d.ts +2 -1
- package/dist/database/helpers/schema/dialects/mssql.js +4 -0
- package/dist/database/helpers/schema/dialects/oracle.d.ts +2 -1
- package/dist/database/helpers/schema/dialects/oracle.js +4 -0
- package/dist/database/helpers/schema/dialects/postgres.d.ts +2 -1
- package/dist/database/helpers/schema/dialects/postgres.js +4 -0
- package/dist/database/helpers/schema/types.d.ts +5 -0
- package/dist/database/helpers/schema/types.js +3 -0
- package/dist/database/helpers/schema/utils/preprocess-bindings.d.ts +8 -0
- package/dist/database/helpers/schema/utils/preprocess-bindings.js +30 -0
- package/dist/database/index.js +14 -6
- package/dist/database/migrations/20240305A-change-useragent-type.js +1 -1
- package/dist/database/migrations/20240716A-update-files-date-fields.js +33 -0
- package/dist/database/migrations/20240806A-permissions-policies.d.ts +6 -0
- package/dist/database/migrations/20240806A-permissions-policies.js +338 -0
- package/dist/database/run-ast/lib/get-db-query.js +12 -2
- package/dist/database/run-ast/utils/apply-case-when.js +5 -4
- package/dist/database/run-ast/utils/with-preprocess-bindings.d.ts +2 -0
- package/dist/database/run-ast/utils/with-preprocess-bindings.js +14 -0
- package/dist/logger/index.js +1 -1
- package/dist/middleware/error-handler.d.ts +2 -2
- package/dist/middleware/error-handler.js +54 -51
- package/dist/permissions/lib/fetch-permissions.d.ts +1 -0
- package/dist/permissions/lib/fetch-permissions.js +3 -2
- package/dist/permissions/lib/fetch-policies.d.ts +7 -0
- package/dist/permissions/lib/fetch-policies.js +16 -1
- package/dist/permissions/modules/process-ast/lib/inject-cases.js +6 -6
- package/dist/permissions/modules/process-ast/types.d.ts +0 -6
- package/dist/permissions/modules/process-ast/utils/extract-paths-from-query.js +11 -1
- package/dist/permissions/utils/filter-policies-by-ip.d.ts +1 -1
- package/dist/services/assets.js +2 -5
- package/dist/services/fields.d.ts +3 -0
- package/dist/services/fields.js +29 -5
- package/dist/services/files/lib/get-sharp-instance.d.ts +2 -0
- package/dist/services/files/lib/get-sharp-instance.js +10 -0
- package/dist/services/files/utils/get-metadata.js +7 -6
- package/dist/services/files.js +5 -0
- package/dist/services/import-export.d.ts +3 -1
- package/dist/services/import-export.js +49 -5
- package/dist/services/mail/index.d.ts +1 -1
- package/dist/services/mail/index.js +9 -1
- package/dist/services/relations.d.ts +3 -1
- package/dist/services/relations.js +27 -5
- package/dist/services/tus/data-store.js +4 -5
- package/dist/services/tus/server.d.ts +1 -1
- package/dist/services/tus/server.js +9 -2
- package/dist/utils/apply-query.d.ts +8 -5
- package/dist/utils/apply-query.js +40 -5
- package/dist/utils/fetch-user-count/fetch-access-lookup.d.ts +2 -0
- package/dist/utils/fetch-user-count/fetch-access-lookup.js +3 -2
- package/dist/utils/fetch-user-count/fetch-user-count.js +10 -3
- package/dist/utils/fetch-user-count/get-user-count-query.js +1 -1
- package/dist/utils/get-schema.js +3 -3
- package/dist/utils/sanitize-schema.d.ts +1 -1
- package/package.json +38 -38
- package/dist/database/migrations/20240710A-permissions-policies.js +0 -169
- /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)
|
|
23
|
-
|
|
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
|
-
|
|
20
|
+
const bindings = [...caseQuery.toSQL().bindings, column];
|
|
21
|
+
let rawCase = `(CASE WHEN ${sql} THEN ?? END)`;
|
|
22
22
|
if (alias) {
|
|
23
|
-
|
|
23
|
+
rawCase += ' AS ??';
|
|
24
|
+
bindings.push(alias);
|
|
24
25
|
}
|
|
25
|
-
return
|
|
26
|
+
return knex.raw(rawCase, bindings);
|
|
26
27
|
}
|
|
@@ -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
|
+
}
|
package/dist/logger/index.js
CHANGED
|
@@ -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,
|
|
2
|
-
import { isObject
|
|
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
|
-
|
|
8
|
-
|
|
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
|
|
12
|
-
errors: [],
|
|
13
|
-
};
|
|
14
|
-
const errors = toArray(err);
|
|
10
|
+
let errors = [];
|
|
15
11
|
let status = null;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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 =
|
|
44
|
+
status = FALLBACK_ERROR.status;
|
|
47
45
|
if (req.accountability?.admin === true) {
|
|
48
46
|
const localError = isObject(error) ? error : {};
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
}
|