@directus/api 23.0.0 → 23.1.1
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 +2 -0
- package/dist/controllers/activity.js +30 -27
- package/dist/controllers/assets.js +1 -1
- package/dist/controllers/comments.d.ts +2 -0
- package/dist/controllers/comments.js +153 -0
- package/dist/controllers/permissions.js +1 -1
- package/dist/controllers/users.js +4 -8
- package/dist/controllers/versions.js +10 -5
- package/dist/database/helpers/schema/dialects/cockroachdb.d.ts +1 -1
- package/dist/database/helpers/schema/dialects/cockroachdb.js +2 -2
- package/dist/database/helpers/schema/dialects/mssql.d.ts +1 -1
- package/dist/database/helpers/schema/dialects/mssql.js +1 -1
- package/dist/database/helpers/schema/dialects/mysql.d.ts +1 -1
- package/dist/database/helpers/schema/dialects/mysql.js +2 -2
- package/dist/database/helpers/schema/dialects/oracle.d.ts +1 -1
- package/dist/database/helpers/schema/dialects/oracle.js +1 -1
- package/dist/database/helpers/schema/dialects/postgres.d.ts +1 -1
- package/dist/database/helpers/schema/dialects/postgres.js +3 -3
- package/dist/database/helpers/schema/types.d.ts +1 -1
- package/dist/database/helpers/schema/types.js +1 -1
- package/dist/database/index.js +3 -0
- package/dist/database/migrations/20240806A-permissions-policies.d.ts +0 -3
- package/dist/database/migrations/20240806A-permissions-policies.js +8 -94
- package/dist/database/migrations/20240909A-separate-comments.d.ts +3 -0
- package/dist/database/migrations/20240909A-separate-comments.js +65 -0
- package/dist/database/migrations/20240909B-consolidate-content-versioning.d.ts +3 -0
- package/dist/database/migrations/20240909B-consolidate-content-versioning.js +10 -0
- package/dist/database/run-ast/lib/get-db-query.d.ts +12 -2
- package/dist/database/run-ast/lib/get-db-query.js +3 -3
- package/dist/database/run-ast/modules/fetch-permitted-ast-root-fields.d.ts +15 -0
- package/dist/database/run-ast/modules/fetch-permitted-ast-root-fields.js +29 -0
- package/dist/database/run-ast/run-ast.js +8 -1
- package/dist/database/run-ast/utils/apply-case-when.js +1 -1
- package/dist/database/run-ast/utils/get-column-pre-processor.d.ts +1 -1
- package/dist/database/run-ast/utils/get-column-pre-processor.js +10 -2
- package/dist/permissions/lib/fetch-permissions.d.ts +1 -1
- package/dist/permissions/lib/fetch-permissions.js +4 -1
- package/dist/permissions/modules/validate-access/lib/validate-item-access.d.ts +2 -1
- package/dist/permissions/modules/validate-access/lib/validate-item-access.js +18 -13
- package/dist/permissions/modules/validate-access/validate-access.d.ts +1 -0
- package/dist/permissions/modules/validate-access/validate-access.js +14 -1
- package/dist/permissions/utils/fetch-share-info.d.ts +12 -0
- package/dist/permissions/utils/fetch-share-info.js +9 -0
- package/dist/permissions/utils/get-permissions-for-share.d.ts +4 -0
- package/dist/permissions/utils/get-permissions-for-share.js +182 -0
- package/dist/permissions/utils/merge-permissions.d.ts +9 -0
- package/dist/permissions/utils/merge-permissions.js +118 -0
- package/dist/services/activity.d.ts +1 -7
- package/dist/services/activity.js +0 -103
- package/dist/services/assets.js +5 -4
- package/dist/services/authentication.js +1 -10
- package/dist/services/collections.js +6 -4
- package/dist/services/comments.d.ts +31 -0
- package/dist/services/comments.js +378 -0
- package/dist/services/graphql/index.js +17 -16
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/items.js +3 -1
- package/dist/services/mail/index.d.ts +2 -1
- package/dist/services/mail/index.js +4 -1
- package/dist/services/payload.js +15 -14
- package/dist/services/shares.d.ts +2 -0
- package/dist/services/shares.js +11 -9
- package/dist/services/users.js +1 -0
- package/dist/services/versions.js +59 -44
- package/dist/types/auth.d.ts +0 -7
- package/dist/utils/apply-diff.js +5 -6
- package/dist/utils/get-accountability-for-token.js +0 -2
- package/dist/utils/get-service.js +3 -1
- package/dist/utils/sanitize-schema.d.ts +1 -1
- package/dist/utils/sanitize-schema.js +2 -0
- package/package.json +52 -52
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Action } from '@directus/constants';
|
|
2
|
+
export async function up(knex) {
|
|
3
|
+
await knex.schema.createTable('directus_comments', (table) => {
|
|
4
|
+
table.uuid('id').primary().notNullable();
|
|
5
|
+
table
|
|
6
|
+
.string('collection', 64)
|
|
7
|
+
.notNullable()
|
|
8
|
+
.references('collection')
|
|
9
|
+
.inTable('directus_collections')
|
|
10
|
+
.onDelete('CASCADE');
|
|
11
|
+
table.string('item').notNullable();
|
|
12
|
+
table.text('comment').notNullable();
|
|
13
|
+
table.timestamp('date_created').defaultTo(knex.fn.now());
|
|
14
|
+
table.timestamp('date_updated').defaultTo(knex.fn.now());
|
|
15
|
+
table.uuid('user_created').references('id').inTable('directus_users').onDelete('SET NULL');
|
|
16
|
+
// Cannot have two constraints from/to the same table, handled on API side
|
|
17
|
+
table.uuid('user_updated').references('id').inTable('directus_users');
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export async function down(knex) {
|
|
21
|
+
const rowsLimit = 50;
|
|
22
|
+
let hasMore = true;
|
|
23
|
+
while (hasMore) {
|
|
24
|
+
const comments = await knex
|
|
25
|
+
.select('id', 'collection', 'item', 'comment', 'date_created', 'user_created')
|
|
26
|
+
.from('directus_comments')
|
|
27
|
+
.limit(rowsLimit);
|
|
28
|
+
if (comments.length === 0) {
|
|
29
|
+
hasMore = false;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
await knex.transaction(async (trx) => {
|
|
33
|
+
for (const comment of comments) {
|
|
34
|
+
const migratedRecords = await trx('directus_activity')
|
|
35
|
+
.select('id')
|
|
36
|
+
.where('collection', '=', 'directus_comments')
|
|
37
|
+
.andWhere('item', '=', comment.id)
|
|
38
|
+
.andWhere('action', '=', Action.CREATE)
|
|
39
|
+
.limit(1);
|
|
40
|
+
if (migratedRecords[0]) {
|
|
41
|
+
await trx('directus_activity')
|
|
42
|
+
.update({
|
|
43
|
+
action: Action.COMMENT,
|
|
44
|
+
collection: comment.collection,
|
|
45
|
+
item: comment.item,
|
|
46
|
+
comment: comment.comment,
|
|
47
|
+
})
|
|
48
|
+
.where('id', '=', migratedRecords[0].id);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
await trx('directus_activity').insert({
|
|
52
|
+
action: Action.COMMENT,
|
|
53
|
+
collection: comment.collection,
|
|
54
|
+
item: comment.item,
|
|
55
|
+
comment: comment.comment,
|
|
56
|
+
user: comment.user_created,
|
|
57
|
+
timestamp: comment.date_created,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
await trx('directus_comments').where('id', '=', comment.id).delete();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
await knex.schema.dropTable('directus_comments');
|
|
65
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export async function up(knex) {
|
|
2
|
+
await knex.schema.alterTable('directus_versions', (table) => {
|
|
3
|
+
table.json('delta');
|
|
4
|
+
});
|
|
5
|
+
}
|
|
6
|
+
export async function down(knex) {
|
|
7
|
+
await knex.schema.alterTable('directus_versions', (table) => {
|
|
8
|
+
table.dropColumn('delta');
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import type { Filter, Permission, Query
|
|
1
|
+
import type { Filter, Permission, Query } from '@directus/types';
|
|
2
2
|
import type { Knex } from 'knex';
|
|
3
|
+
import type { Context } from '../../../permissions/types.js';
|
|
3
4
|
import type { FieldNode, FunctionFieldNode, O2MNode } from '../../../types/ast.js';
|
|
4
|
-
export
|
|
5
|
+
export type DBQueryOptions = {
|
|
6
|
+
table: string;
|
|
7
|
+
fieldNodes: (FieldNode | FunctionFieldNode)[];
|
|
8
|
+
o2mNodes: O2MNode[];
|
|
9
|
+
query: Query;
|
|
10
|
+
cases: Filter[];
|
|
11
|
+
permissions: Permission[];
|
|
12
|
+
permissionsOnly?: boolean;
|
|
13
|
+
};
|
|
14
|
+
export declare function getDBQuery({ table, fieldNodes, o2mNodes, query, cases, permissions, permissionsOnly }: DBQueryOptions, { knex, schema }: Context): Knex.QueryBuilder;
|
|
@@ -9,10 +9,10 @@ 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
11
|
import { withPreprocessBindings } from '../utils/with-preprocess-bindings.js';
|
|
12
|
-
export function getDBQuery(
|
|
12
|
+
export function getDBQuery({ table, fieldNodes, o2mNodes, query, cases, permissions, permissionsOnly }, { knex, schema }) {
|
|
13
13
|
const aliasMap = Object.create(null);
|
|
14
14
|
const env = useEnv();
|
|
15
|
-
const preProcess = getColumnPreprocessor(knex, schema, table, cases, permissions, aliasMap);
|
|
15
|
+
const preProcess = getColumnPreprocessor(knex, schema, table, cases, permissions, aliasMap, permissionsOnly);
|
|
16
16
|
const queryCopy = cloneDeep(query);
|
|
17
17
|
const helpers = getHelpers(knex);
|
|
18
18
|
const hasCaseWhen = o2mNodes.some((node) => node.whenCase && node.whenCase.length > 0) ||
|
|
@@ -181,7 +181,7 @@ export function getDBQuery(schema, knex, table, fieldNodes, o2mNodes, query, cas
|
|
|
181
181
|
// Since the fields are expected to be the same for a single primary key it is safe to include them in the
|
|
182
182
|
// group by without influencing the result.
|
|
183
183
|
// This inclusion depends on the DB vendor, as such it is handled in a dialect specific helper.
|
|
184
|
-
helpers.schema.addInnerSortFieldsToGroupBy(groupByFields, innerQuerySortRecords, hasMultiRelationalSort ?? false);
|
|
184
|
+
helpers.schema.addInnerSortFieldsToGroupBy(groupByFields, innerQuerySortRecords, (hasMultiRelationalSort || sortRecords?.some(({ column }) => column.includes('.'))) ?? false);
|
|
185
185
|
dbQuery.groupBy(groupByFields);
|
|
186
186
|
}
|
|
187
187
|
const wrapperQuery = knex
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Accountability, PermissionsAction, SchemaOverview } from '@directus/types';
|
|
2
|
+
import type { Knex } from 'knex';
|
|
3
|
+
import type { AST } from '../../../types/ast.js';
|
|
4
|
+
type FetchPermittedAstRootFieldsOptions = {
|
|
5
|
+
schema: SchemaOverview;
|
|
6
|
+
accountability: Accountability;
|
|
7
|
+
knex: Knex;
|
|
8
|
+
action: PermissionsAction;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Fetch the permitted top level fields of a given root type AST using a case/when query that is constructed the
|
|
12
|
+
* same way as `runAst` but only returns flags (1/null) instead of actual field values.
|
|
13
|
+
*/
|
|
14
|
+
export declare function fetchPermittedAstRootFields(originalAST: AST, { schema, accountability, knex, action }: FetchPermittedAstRootFieldsOptions): Promise<any>;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { cloneDeep } from 'lodash-es';
|
|
2
|
+
import { fetchPermissions } from '../../../permissions/lib/fetch-permissions.js';
|
|
3
|
+
import { fetchPolicies } from '../../../permissions/lib/fetch-policies.js';
|
|
4
|
+
import { getDBQuery } from '../lib/get-db-query.js';
|
|
5
|
+
import { parseCurrentLevel } from '../lib/parse-current-level.js';
|
|
6
|
+
/**
|
|
7
|
+
* Fetch the permitted top level fields of a given root type AST using a case/when query that is constructed the
|
|
8
|
+
* same way as `runAst` but only returns flags (1/null) instead of actual field values.
|
|
9
|
+
*/
|
|
10
|
+
export async function fetchPermittedAstRootFields(originalAST, { schema, accountability, knex, action }) {
|
|
11
|
+
const ast = cloneDeep(originalAST);
|
|
12
|
+
const { name: collection, children, cases, query } = ast;
|
|
13
|
+
// Retrieve the database columns to select in the current AST
|
|
14
|
+
const { fieldNodes } = await parseCurrentLevel(schema, collection, children, query);
|
|
15
|
+
let permissions = [];
|
|
16
|
+
if (accountability && !accountability.admin) {
|
|
17
|
+
const policies = await fetchPolicies(accountability, { schema, knex });
|
|
18
|
+
permissions = await fetchPermissions({ action, accountability, policies }, { schema, knex });
|
|
19
|
+
}
|
|
20
|
+
return getDBQuery({
|
|
21
|
+
table: collection,
|
|
22
|
+
fieldNodes,
|
|
23
|
+
o2mNodes: [],
|
|
24
|
+
query,
|
|
25
|
+
cases,
|
|
26
|
+
permissions,
|
|
27
|
+
permissionsOnly: true,
|
|
28
|
+
}, { schema, knex });
|
|
29
|
+
}
|
|
@@ -36,7 +36,14 @@ export async function runAst(originalAST, schema, accountability, options) {
|
|
|
36
36
|
permissions = await fetchPermissions({ action: 'read', accountability, policies }, { schema, knex });
|
|
37
37
|
}
|
|
38
38
|
// The actual knex query builder instance. This is a promise that resolves with the raw items from the db
|
|
39
|
-
const dbQuery = getDBQuery(
|
|
39
|
+
const dbQuery = getDBQuery({
|
|
40
|
+
table: collection,
|
|
41
|
+
fieldNodes,
|
|
42
|
+
o2mNodes,
|
|
43
|
+
query,
|
|
44
|
+
cases,
|
|
45
|
+
permissions,
|
|
46
|
+
}, { schema, knex });
|
|
40
47
|
const rawItems = await dbQuery;
|
|
41
48
|
if (!rawItems)
|
|
42
49
|
return null;
|
|
@@ -16,7 +16,7 @@ export function applyCaseWhen({ columnCases, table, aliasMap, cases, column, ali
|
|
|
16
16
|
sqlParts.push(val);
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
const sql = sqlParts.join(' ');
|
|
19
|
+
const sql = sqlParts.length > 0 ? sqlParts.join(' ') : '1';
|
|
20
20
|
const bindings = [...caseQuery.toSQL().bindings, column];
|
|
21
21
|
let rawCase = `(CASE WHEN ${sql} THEN ?? END)`;
|
|
22
22
|
if (alias) {
|
|
@@ -6,5 +6,5 @@ interface NodePreProcessOptions {
|
|
|
6
6
|
/** Don't assign an alias to the column but instead return the column as is */
|
|
7
7
|
noAlias?: boolean;
|
|
8
8
|
}
|
|
9
|
-
export declare function getColumnPreprocessor(knex: Knex, schema: SchemaOverview, table: string, cases: Filter[], permissions: Permission[], aliasMap: AliasMap): (fieldNode: FieldNode | FunctionFieldNode | M2ONode, options?: NodePreProcessOptions) => Knex.Raw<string>;
|
|
9
|
+
export declare function getColumnPreprocessor(knex: Knex, schema: SchemaOverview, table: string, cases: Filter[], permissions: Permission[], aliasMap: AliasMap, permissionsOnly?: boolean): (fieldNode: FieldNode | FunctionFieldNode | M2ONode, options?: NodePreProcessOptions) => Knex.Raw<string>;
|
|
10
10
|
export {};
|
|
@@ -4,7 +4,7 @@ import { parseFilterKey } from '../../../utils/parse-filter-key.js';
|
|
|
4
4
|
import { getHelpers } from '../../helpers/index.js';
|
|
5
5
|
import { applyCaseWhen } from './apply-case-when.js';
|
|
6
6
|
import { getNodeAlias } from './get-field-alias.js';
|
|
7
|
-
export function getColumnPreprocessor(knex, schema, table, cases, permissions, aliasMap) {
|
|
7
|
+
export function getColumnPreprocessor(knex, schema, table, cases, permissions, aliasMap, permissionsOnly) {
|
|
8
8
|
const helpers = getHelpers(knex);
|
|
9
9
|
return function (fieldNode, options) {
|
|
10
10
|
// Don't assign an alias to the column expression if the field has a whenCase
|
|
@@ -22,7 +22,15 @@ export function getColumnPreprocessor(knex, schema, table, cases, permissions, a
|
|
|
22
22
|
field = schema.collections[fieldNode.relation.collection].fields[fieldNode.relation.field];
|
|
23
23
|
}
|
|
24
24
|
let column;
|
|
25
|
-
if (
|
|
25
|
+
if (permissionsOnly) {
|
|
26
|
+
if (noAlias) {
|
|
27
|
+
column = knex.raw(1);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
column = knex.raw('1 as ??', [alias]);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
else if (field?.type?.startsWith('geometry')) {
|
|
26
34
|
column = helpers.st.asText(table, field.field, rawColumnAlias);
|
|
27
35
|
}
|
|
28
36
|
else if (fieldNode.type === 'functionField') {
|
|
@@ -4,7 +4,7 @@ export interface FetchPermissionsOptions {
|
|
|
4
4
|
action?: PermissionsAction;
|
|
5
5
|
policies: string[];
|
|
6
6
|
collections?: string[];
|
|
7
|
-
accountability?: Pick<Accountability, 'user' | 'role' | 'roles' | 'app'>;
|
|
7
|
+
accountability?: Pick<Accountability, 'user' | 'role' | 'roles' | 'app' | 'share' | 'ip'>;
|
|
8
8
|
bypassDynamicVariableProcessing?: boolean;
|
|
9
9
|
}
|
|
10
10
|
export declare function fetchPermissions(options: FetchPermissionsOptions, context: Context): Promise<{
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fetchDynamicVariableContext } from '../utils/fetch-dynamic-variable-context.js';
|
|
2
2
|
import { fetchRawPermissions } from '../utils/fetch-raw-permissions.js';
|
|
3
3
|
import { processPermissions } from '../utils/process-permissions.js';
|
|
4
|
+
import { getPermissionsForShare } from '../utils/get-permissions-for-share.js';
|
|
4
5
|
export async function fetchPermissions(options, context) {
|
|
5
6
|
const permissions = await fetchRawPermissions({ ...options, bypassMinimalAppPermissions: options.bypassDynamicVariableProcessing ?? false }, context);
|
|
6
7
|
if (options.accountability && !options.bypassDynamicVariableProcessing) {
|
|
@@ -15,7 +16,9 @@ export async function fetchPermissions(options, context) {
|
|
|
15
16
|
accountability: options.accountability,
|
|
16
17
|
permissionsContext,
|
|
17
18
|
});
|
|
18
|
-
|
|
19
|
+
if (options.accountability.share && (options.action === undefined || options.action === 'read')) {
|
|
20
|
+
return await getPermissionsForShare(options.accountability, options.collections, context);
|
|
21
|
+
}
|
|
19
22
|
return processedPermissions;
|
|
20
23
|
}
|
|
21
24
|
return permissions;
|
|
@@ -5,5 +5,6 @@ export interface ValidateItemAccessOptions {
|
|
|
5
5
|
action: PermissionsAction;
|
|
6
6
|
collection: string;
|
|
7
7
|
primaryKeys: PrimaryKey[];
|
|
8
|
+
fields?: string[];
|
|
8
9
|
}
|
|
9
|
-
export declare function validateItemAccess(options: ValidateItemAccessOptions, context: Context): Promise<
|
|
10
|
+
export declare function validateItemAccess(options: ValidateItemAccessOptions, context: Context): Promise<any>;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { runAst } from '../../../../database/run-ast/run-ast.js';
|
|
1
|
+
import { fetchPermittedAstRootFields } from '../../../../database/run-ast/modules/fetch-permitted-ast-root-fields.js';
|
|
3
2
|
import { processAst } from '../../process-ast/process-ast.js';
|
|
4
3
|
export async function validateItemAccess(options, context) {
|
|
5
4
|
const primaryKeyField = context.schema.collections[options.collection]?.primary;
|
|
@@ -8,17 +7,14 @@ export async function validateItemAccess(options, context) {
|
|
|
8
7
|
}
|
|
9
8
|
// When we're looking up access to specific items, we have to read them from the database to
|
|
10
9
|
// make sure you are allowed to access them.
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
const ast = {
|
|
11
|
+
type: 'root',
|
|
12
|
+
name: options.collection,
|
|
13
|
+
query: { limit: options.primaryKeys.length },
|
|
14
|
+
// Act as if every field was a "normal" field
|
|
15
|
+
children: options.fields?.map((field) => ({ type: 'field', name: field, fieldKey: field, whenCase: [] })) ?? [],
|
|
16
|
+
cases: [],
|
|
16
17
|
};
|
|
17
|
-
const ast = await getAstFromQuery({
|
|
18
|
-
accountability: options.accountability,
|
|
19
|
-
query,
|
|
20
|
-
collection: options.collection,
|
|
21
|
-
}, context);
|
|
22
18
|
await processAst({ ast, ...options }, context);
|
|
23
19
|
// Inject the filter after the permissions have been processed, as to not require access to the primary key
|
|
24
20
|
ast.query.filter = {
|
|
@@ -26,8 +22,17 @@ export async function validateItemAccess(options, context) {
|
|
|
26
22
|
_in: options.primaryKeys,
|
|
27
23
|
},
|
|
28
24
|
};
|
|
29
|
-
const items = await
|
|
25
|
+
const items = await fetchPermittedAstRootFields(ast, {
|
|
26
|
+
schema: context.schema,
|
|
27
|
+
accountability: options.accountability,
|
|
28
|
+
knex: context.knex,
|
|
29
|
+
action: options.action,
|
|
30
|
+
});
|
|
30
31
|
if (items && items.length === options.primaryKeys.length) {
|
|
32
|
+
const { fields } = options;
|
|
33
|
+
if (fields) {
|
|
34
|
+
return items.every((item) => fields.every((field) => item[field] === 1));
|
|
35
|
+
}
|
|
31
36
|
return true;
|
|
32
37
|
}
|
|
33
38
|
return false;
|
|
@@ -7,6 +7,12 @@ import { validateItemAccess } from './lib/validate-item-access.js';
|
|
|
7
7
|
* control rules and checking if we got the expected result back
|
|
8
8
|
*/
|
|
9
9
|
export async function validateAccess(options, context) {
|
|
10
|
+
// Skip further validation if the collection does not exist
|
|
11
|
+
if (options.collection in context.schema.collections === false) {
|
|
12
|
+
throw new ForbiddenError({
|
|
13
|
+
reason: `You don't have permission to "${options.action}" from collection "${options.collection}" or it does not exist.`,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
10
16
|
if (options.accountability.admin === true) {
|
|
11
17
|
return;
|
|
12
18
|
}
|
|
@@ -21,8 +27,15 @@ export async function validateAccess(options, context) {
|
|
|
21
27
|
access = await validateCollectionAccess(options, context);
|
|
22
28
|
}
|
|
23
29
|
if (!access) {
|
|
30
|
+
if (options.fields?.length ?? 0 > 0) {
|
|
31
|
+
throw new ForbiddenError({
|
|
32
|
+
reason: `You don't have permissions to perform "${options.action}" for the field(s) ${options
|
|
33
|
+
.fields.map((field) => `"${field}"`)
|
|
34
|
+
.join(', ')} in collection "${options.collection}" or it does not exist.`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
24
37
|
throw new ForbiddenError({
|
|
25
|
-
reason: `You don't have permission to "${options.action}"
|
|
38
|
+
reason: `You don't have permission to perform "${options.action}" for collection "${options.collection}" or it does not exist.`,
|
|
26
39
|
});
|
|
27
40
|
}
|
|
28
41
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AbstractServiceOptions } from '../../types/services.js';
|
|
2
|
+
export interface ShareInfo {
|
|
3
|
+
collection: string;
|
|
4
|
+
item: string;
|
|
5
|
+
role: string | null;
|
|
6
|
+
user_created: {
|
|
7
|
+
id: string;
|
|
8
|
+
role: string;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export declare const fetchShareInfo: typeof _fetchShareInfo;
|
|
12
|
+
export declare function _fetchShareInfo(shareId: string, context: AbstractServiceOptions): Promise<ShareInfo>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { withCache } from './with-cache.js';
|
|
2
|
+
export const fetchShareInfo = withCache('share-info', _fetchShareInfo);
|
|
3
|
+
export async function _fetchShareInfo(shareId, context) {
|
|
4
|
+
const { SharesService } = await import('../../services/shares.js');
|
|
5
|
+
const sharesService = new SharesService(context);
|
|
6
|
+
return (await sharesService.readOne(shareId, {
|
|
7
|
+
fields: ['collection', 'item', 'role', 'user_created.id', 'user_created.role'],
|
|
8
|
+
}));
|
|
9
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Accountability, Permission, SchemaOverview } from '@directus/types';
|
|
2
|
+
import type { Context } from '../types.js';
|
|
3
|
+
export declare function getPermissionsForShare(accountability: Pick<Accountability, 'share' | 'ip'>, collections: string[] | undefined, context: Context): Promise<Permission[]>;
|
|
4
|
+
export declare function traverse(schema: SchemaOverview, rootItemPrimaryKeyField: string, rootItemPrimaryKey: string, currentCollection: string, parentCollections?: string[], path?: string[]): Partial<Permission>[];
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { schemaPermissions } from '@directus/system-data';
|
|
2
|
+
import { set, uniq } from 'lodash-es';
|
|
3
|
+
import { fetchAllowedFieldMap } from '../modules/fetch-allowed-field-map/fetch-allowed-field-map.js';
|
|
4
|
+
import { fetchShareInfo } from './fetch-share-info.js';
|
|
5
|
+
import { mergePermissions } from './merge-permissions.js';
|
|
6
|
+
import { fetchPermissions } from '../lib/fetch-permissions.js';
|
|
7
|
+
import { fetchPolicies } from '../lib/fetch-policies.js';
|
|
8
|
+
import { fetchRolesTree } from '../lib/fetch-roles-tree.js';
|
|
9
|
+
import { reduceSchema } from '../../utils/reduce-schema.js';
|
|
10
|
+
import { fetchGlobalAccess } from '../modules/fetch-global-access/fetch-global-access.js';
|
|
11
|
+
export async function getPermissionsForShare(accountability, collections, context) {
|
|
12
|
+
const defaults = {
|
|
13
|
+
action: 'read',
|
|
14
|
+
collection: '',
|
|
15
|
+
permissions: {},
|
|
16
|
+
policy: null,
|
|
17
|
+
validation: null,
|
|
18
|
+
presets: null,
|
|
19
|
+
fields: null,
|
|
20
|
+
};
|
|
21
|
+
const { collection, item, role, user_created } = await fetchShareInfo(accountability.share, context);
|
|
22
|
+
const userAccountability = {
|
|
23
|
+
user: user_created.id,
|
|
24
|
+
role: user_created.role,
|
|
25
|
+
roles: await fetchRolesTree(user_created.role, context.knex),
|
|
26
|
+
admin: false,
|
|
27
|
+
app: false,
|
|
28
|
+
ip: accountability.ip,
|
|
29
|
+
};
|
|
30
|
+
// Fallback to public accountability so merging later on has no issues
|
|
31
|
+
const shareAccountability = {
|
|
32
|
+
user: null,
|
|
33
|
+
role: role,
|
|
34
|
+
roles: await fetchRolesTree(role, context.knex),
|
|
35
|
+
admin: false,
|
|
36
|
+
app: false,
|
|
37
|
+
ip: accountability.ip,
|
|
38
|
+
};
|
|
39
|
+
const [{ admin: shareIsAdmin }, { admin: userIsAdmin }, userPermissions, sharePermissions, shareFieldMap, userFieldMap,] = await Promise.all([
|
|
40
|
+
fetchGlobalAccess(shareAccountability, context.knex),
|
|
41
|
+
fetchGlobalAccess(userAccountability, context.knex),
|
|
42
|
+
getPermissionsForAccountability(userAccountability, context),
|
|
43
|
+
getPermissionsForAccountability(shareAccountability, context),
|
|
44
|
+
fetchAllowedFieldMap({
|
|
45
|
+
accountability: shareAccountability,
|
|
46
|
+
action: 'read',
|
|
47
|
+
}, context),
|
|
48
|
+
fetchAllowedFieldMap({
|
|
49
|
+
accountability: userAccountability,
|
|
50
|
+
action: 'read',
|
|
51
|
+
}, context),
|
|
52
|
+
]);
|
|
53
|
+
const isAdmin = userIsAdmin && shareIsAdmin;
|
|
54
|
+
let permissions = [];
|
|
55
|
+
let reducedSchema;
|
|
56
|
+
if (isAdmin) {
|
|
57
|
+
defaults.fields = ['*'];
|
|
58
|
+
reducedSchema = context.schema;
|
|
59
|
+
}
|
|
60
|
+
else if (userIsAdmin && !shareIsAdmin) {
|
|
61
|
+
permissions = sharePermissions;
|
|
62
|
+
reducedSchema = reduceSchema(context.schema, shareFieldMap);
|
|
63
|
+
}
|
|
64
|
+
else if (shareIsAdmin && !userIsAdmin) {
|
|
65
|
+
permissions = userPermissions;
|
|
66
|
+
reducedSchema = reduceSchema(context.schema, userFieldMap);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
permissions = mergePermissions('intersection', sharePermissions, userPermissions);
|
|
70
|
+
reducedSchema = reduceSchema(context.schema, shareFieldMap);
|
|
71
|
+
reducedSchema = reduceSchema(reducedSchema, userFieldMap);
|
|
72
|
+
}
|
|
73
|
+
const parentPrimaryKeyField = context.schema.collections[collection].primary;
|
|
74
|
+
const relationalPermissions = traverse(reducedSchema, parentPrimaryKeyField, item, collection);
|
|
75
|
+
const parentCollectionPermission = {
|
|
76
|
+
...defaults,
|
|
77
|
+
collection,
|
|
78
|
+
permissions: {
|
|
79
|
+
[parentPrimaryKeyField]: {
|
|
80
|
+
_eq: item,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
// All permissions that will be merged into the original permissions set
|
|
85
|
+
const allGeneratedPermissions = [
|
|
86
|
+
parentCollectionPermission,
|
|
87
|
+
...relationalPermissions.map((generated) => ({ ...defaults, ...generated })),
|
|
88
|
+
...schemaPermissions,
|
|
89
|
+
];
|
|
90
|
+
// All the collections that are touched through the relational tree from the current root collection, and the schema collections
|
|
91
|
+
const allowedCollections = uniq(allGeneratedPermissions.map(({ collection }) => collection));
|
|
92
|
+
const generatedPermissions = [];
|
|
93
|
+
// Merge all the permissions that relate to the same collection with an _or (this allows you to properly retrieve)
|
|
94
|
+
// the items of a collection if you entered that collection from multiple angles
|
|
95
|
+
for (const collection of allowedCollections) {
|
|
96
|
+
const permissionsForCollection = allGeneratedPermissions.filter((permission) => permission.collection === collection);
|
|
97
|
+
if (permissionsForCollection.length > 0) {
|
|
98
|
+
generatedPermissions.push(...mergePermissions('or', permissionsForCollection));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
generatedPermissions.push(...permissionsForCollection);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (isAdmin) {
|
|
105
|
+
return filterCollections(collections, generatedPermissions);
|
|
106
|
+
}
|
|
107
|
+
// Explicitly filter out permissions to collections unrelated to the root parent item.
|
|
108
|
+
const limitedPermissions = permissions.filter(({ action, collection }) => allowedCollections.includes(collection) && action === 'read');
|
|
109
|
+
return filterCollections(collections, mergePermissions('and', limitedPermissions, generatedPermissions));
|
|
110
|
+
}
|
|
111
|
+
function filterCollections(collections, permissions) {
|
|
112
|
+
if (!collections) {
|
|
113
|
+
return permissions;
|
|
114
|
+
}
|
|
115
|
+
return permissions.filter(({ collection }) => collections.includes(collection));
|
|
116
|
+
}
|
|
117
|
+
async function getPermissionsForAccountability(accountability, context) {
|
|
118
|
+
const policies = await fetchPolicies(accountability, context);
|
|
119
|
+
return fetchPermissions({
|
|
120
|
+
policies,
|
|
121
|
+
accountability,
|
|
122
|
+
}, context);
|
|
123
|
+
}
|
|
124
|
+
export function traverse(schema, rootItemPrimaryKeyField, rootItemPrimaryKey, currentCollection, parentCollections = [], path = []) {
|
|
125
|
+
const permissions = [];
|
|
126
|
+
// If there's already a permissions rule for the collection we're currently checking, we'll shortcircuit.
|
|
127
|
+
// This prevents infinite loop in recursive relationships, like articles->related_articles->articles, or
|
|
128
|
+
// articles.author->users.avatar->files.created_by->users.avatar->files.created_by->🔁
|
|
129
|
+
if (parentCollections.includes(currentCollection)) {
|
|
130
|
+
return permissions;
|
|
131
|
+
}
|
|
132
|
+
const relationsInCollection = schema.relations.filter((relation) => {
|
|
133
|
+
return relation.collection === currentCollection || relation.related_collection === currentCollection;
|
|
134
|
+
});
|
|
135
|
+
for (const relation of relationsInCollection) {
|
|
136
|
+
let type;
|
|
137
|
+
if (relation.related_collection === currentCollection) {
|
|
138
|
+
type = 'o2m';
|
|
139
|
+
}
|
|
140
|
+
else if (!relation.related_collection) {
|
|
141
|
+
type = 'a2o';
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
type = 'm2o';
|
|
145
|
+
}
|
|
146
|
+
if (type === 'o2m') {
|
|
147
|
+
permissions.push({
|
|
148
|
+
collection: relation.collection,
|
|
149
|
+
permissions: getFilterForPath(type, [...path, relation.field], rootItemPrimaryKeyField, rootItemPrimaryKey),
|
|
150
|
+
});
|
|
151
|
+
permissions.push(...traverse(schema, rootItemPrimaryKeyField, rootItemPrimaryKey, relation.collection, [...parentCollections, currentCollection], [...path, relation.field]));
|
|
152
|
+
}
|
|
153
|
+
if (type === 'a2o' && relation.meta?.one_allowed_collections) {
|
|
154
|
+
for (const collection of relation.meta.one_allowed_collections) {
|
|
155
|
+
permissions.push({
|
|
156
|
+
collection,
|
|
157
|
+
permissions: getFilterForPath(type, [...path, `$FOLLOW(${relation.collection},${relation.field},${relation.meta.one_collection_field})`], rootItemPrimaryKeyField, rootItemPrimaryKey),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (type === 'm2o') {
|
|
162
|
+
permissions.push({
|
|
163
|
+
collection: relation.related_collection,
|
|
164
|
+
permissions: getFilterForPath(type, [...path, `$FOLLOW(${relation.collection},${relation.field})`], rootItemPrimaryKeyField, rootItemPrimaryKey),
|
|
165
|
+
});
|
|
166
|
+
if (relation.meta?.one_field) {
|
|
167
|
+
permissions.push(...traverse(schema, rootItemPrimaryKeyField, rootItemPrimaryKey, relation.related_collection, [...parentCollections, currentCollection], [...path, relation.meta?.one_field]));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return permissions;
|
|
172
|
+
}
|
|
173
|
+
function getFilterForPath(type, path, rootPrimaryKeyField, rootPrimaryKey) {
|
|
174
|
+
const filter = {};
|
|
175
|
+
if (type === 'm2o' || type === 'a2o') {
|
|
176
|
+
set(filter, path.reverse(), { [rootPrimaryKeyField]: { _eq: rootPrimaryKey } });
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
set(filter, path.reverse(), { _eq: rootPrimaryKey });
|
|
180
|
+
}
|
|
181
|
+
return filter;
|
|
182
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Permission } from '@directus/types';
|
|
2
|
+
/**
|
|
3
|
+
* Merges multiple permission lists into a flat list of unique permissions.
|
|
4
|
+
* @param strategy `and` or `or` deduplicate permissions while `intersection` makes sure only common permissions across all lists are kept and overlapping permissions are merged through `and`.
|
|
5
|
+
* @param permissions List of permission lists to merge.
|
|
6
|
+
* @returns A flat list of unique permissions.
|
|
7
|
+
*/
|
|
8
|
+
export declare function mergePermissions(strategy: 'and' | 'or' | 'intersection', ...permissions: Permission[][]): Permission[];
|
|
9
|
+
export declare function mergePermission(strategy: 'and' | 'or', currentPerm: Permission, newPerm: Permission): Omit<Permission, 'id' | 'system'>;
|