@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.
Files changed (72) hide show
  1. package/dist/app.js +2 -0
  2. package/dist/controllers/activity.js +30 -27
  3. package/dist/controllers/assets.js +1 -1
  4. package/dist/controllers/comments.d.ts +2 -0
  5. package/dist/controllers/comments.js +153 -0
  6. package/dist/controllers/permissions.js +1 -1
  7. package/dist/controllers/users.js +4 -8
  8. package/dist/controllers/versions.js +10 -5
  9. package/dist/database/helpers/schema/dialects/cockroachdb.d.ts +1 -1
  10. package/dist/database/helpers/schema/dialects/cockroachdb.js +2 -2
  11. package/dist/database/helpers/schema/dialects/mssql.d.ts +1 -1
  12. package/dist/database/helpers/schema/dialects/mssql.js +1 -1
  13. package/dist/database/helpers/schema/dialects/mysql.d.ts +1 -1
  14. package/dist/database/helpers/schema/dialects/mysql.js +2 -2
  15. package/dist/database/helpers/schema/dialects/oracle.d.ts +1 -1
  16. package/dist/database/helpers/schema/dialects/oracle.js +1 -1
  17. package/dist/database/helpers/schema/dialects/postgres.d.ts +1 -1
  18. package/dist/database/helpers/schema/dialects/postgres.js +3 -3
  19. package/dist/database/helpers/schema/types.d.ts +1 -1
  20. package/dist/database/helpers/schema/types.js +1 -1
  21. package/dist/database/index.js +3 -0
  22. package/dist/database/migrations/20240806A-permissions-policies.d.ts +0 -3
  23. package/dist/database/migrations/20240806A-permissions-policies.js +8 -94
  24. package/dist/database/migrations/20240909A-separate-comments.d.ts +3 -0
  25. package/dist/database/migrations/20240909A-separate-comments.js +65 -0
  26. package/dist/database/migrations/20240909B-consolidate-content-versioning.d.ts +3 -0
  27. package/dist/database/migrations/20240909B-consolidate-content-versioning.js +10 -0
  28. package/dist/database/run-ast/lib/get-db-query.d.ts +12 -2
  29. package/dist/database/run-ast/lib/get-db-query.js +3 -3
  30. package/dist/database/run-ast/modules/fetch-permitted-ast-root-fields.d.ts +15 -0
  31. package/dist/database/run-ast/modules/fetch-permitted-ast-root-fields.js +29 -0
  32. package/dist/database/run-ast/run-ast.js +8 -1
  33. package/dist/database/run-ast/utils/apply-case-when.js +1 -1
  34. package/dist/database/run-ast/utils/get-column-pre-processor.d.ts +1 -1
  35. package/dist/database/run-ast/utils/get-column-pre-processor.js +10 -2
  36. package/dist/permissions/lib/fetch-permissions.d.ts +1 -1
  37. package/dist/permissions/lib/fetch-permissions.js +4 -1
  38. package/dist/permissions/modules/validate-access/lib/validate-item-access.d.ts +2 -1
  39. package/dist/permissions/modules/validate-access/lib/validate-item-access.js +18 -13
  40. package/dist/permissions/modules/validate-access/validate-access.d.ts +1 -0
  41. package/dist/permissions/modules/validate-access/validate-access.js +14 -1
  42. package/dist/permissions/utils/fetch-share-info.d.ts +12 -0
  43. package/dist/permissions/utils/fetch-share-info.js +9 -0
  44. package/dist/permissions/utils/get-permissions-for-share.d.ts +4 -0
  45. package/dist/permissions/utils/get-permissions-for-share.js +182 -0
  46. package/dist/permissions/utils/merge-permissions.d.ts +9 -0
  47. package/dist/permissions/utils/merge-permissions.js +118 -0
  48. package/dist/services/activity.d.ts +1 -7
  49. package/dist/services/activity.js +0 -103
  50. package/dist/services/assets.js +5 -4
  51. package/dist/services/authentication.js +1 -10
  52. package/dist/services/collections.js +6 -4
  53. package/dist/services/comments.d.ts +31 -0
  54. package/dist/services/comments.js +378 -0
  55. package/dist/services/graphql/index.js +17 -16
  56. package/dist/services/index.d.ts +1 -0
  57. package/dist/services/index.js +1 -0
  58. package/dist/services/items.js +3 -1
  59. package/dist/services/mail/index.d.ts +2 -1
  60. package/dist/services/mail/index.js +4 -1
  61. package/dist/services/payload.js +15 -14
  62. package/dist/services/shares.d.ts +2 -0
  63. package/dist/services/shares.js +11 -9
  64. package/dist/services/users.js +1 -0
  65. package/dist/services/versions.js +59 -44
  66. package/dist/types/auth.d.ts +0 -7
  67. package/dist/utils/apply-diff.js +5 -6
  68. package/dist/utils/get-accountability-for-token.js +0 -2
  69. package/dist/utils/get-service.js +3 -1
  70. package/dist/utils/sanitize-schema.d.ts +1 -1
  71. package/dist/utils/sanitize-schema.js +2 -0
  72. 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,3 @@
1
+ import type { Knex } from 'knex';
2
+ export declare function up(knex: Knex): Promise<void>;
3
+ export declare function down(knex: Knex): Promise<void>;
@@ -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, SchemaOverview } from '@directus/types';
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 declare function getDBQuery(schema: SchemaOverview, knex: Knex, table: string, fieldNodes: (FieldNode | FunctionFieldNode)[], o2mNodes: O2MNode[], query: Query, cases: Filter[], permissions: Permission[]): Knex.QueryBuilder;
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(schema, knex, table, fieldNodes, o2mNodes, query, cases, permissions) {
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(schema, knex, collection, fieldNodes, o2mNodes, query, cases, permissions);
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 (field?.type?.startsWith('geometry')) {
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
- // TODO merge in permissions coming from the share scope
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<boolean>;
10
+ export declare function validateItemAccess(options: ValidateItemAccessOptions, context: Context): Promise<any>;
@@ -1,5 +1,4 @@
1
- import { getAstFromQuery } from '../../../../database/get-ast-from-query/get-ast-from-query.js';
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 query = {
12
- // We don't actually need any of the field data, just want to know if we can read the item as
13
- // whole or not
14
- fields: [],
15
- limit: options.primaryKeys.length,
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 runAst(ast, context.schema, options.accountability, { knex: context.knex });
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;
@@ -5,6 +5,7 @@ export interface ValidateAccessOptions {
5
5
  action: PermissionsAction;
6
6
  collection: string;
7
7
  primaryKeys?: PrimaryKey[];
8
+ fields?: string[];
8
9
  }
9
10
  /**
10
11
  * Validate if the current user has access to perform action against the given collection and
@@ -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}" from collection "${options.collection}" or it does not exist.`,
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'>;