@wbce-d9/api 12.7.0 → 12.7.2

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.
@@ -1,2 +1,2 @@
1
1
  import type { InternalAxiosRequestConfig } from 'axios';
2
- export declare const requestInterceptor: (config: InternalAxiosRequestConfig) => Promise<InternalAxiosRequestConfig<any>>;
2
+ export declare const requestInterceptor: (config: InternalAxiosRequestConfig) => Promise<InternalAxiosRequestConfig<any, any>>;
@@ -1,2 +1,2 @@
1
1
  import type { AxiosResponse } from 'axios';
2
- export declare const responseInterceptor: (config: AxiosResponse<any, any>) => Promise<AxiosResponse<any, any, {}>>;
2
+ export declare const responseInterceptor: (config: AxiosResponse<any, any>) => Promise<AxiosResponse<any, any, {}, any>>;
@@ -36,6 +36,7 @@ export class AuthorizationService {
36
36
  validateFields(ast);
37
37
  validateFilterPermissions(ast, this.schema, action, this.accountability);
38
38
  applyFilters(ast, this.accountability);
39
+ applySearch(ast, this.schema, this.accountability);
39
40
  return ast;
40
41
  /**
41
42
  * Traverses the AST and returns an array of all collections that are being fetched
@@ -332,6 +333,41 @@ export class AuthorizationService {
332
333
  delete query.filter;
333
334
  }
334
335
  }
336
+ /**
337
+ * Sets the fields matched by the search query to the ones the role is allowed to read, so that the search can't
338
+ * be used to enumerate the values of the other fields
339
+ */
340
+ function applySearch(ast, schema, accountability) {
341
+ if (ast.type === 'field' || ast.type === 'functionField')
342
+ return;
343
+ if (ast.type === 'a2o') {
344
+ for (const [collection, children] of Object.entries(ast.children)) {
345
+ setSearchFields(collection, ast.query[collection]);
346
+ for (const child of children)
347
+ applySearch(child, schema, accountability);
348
+ }
349
+ }
350
+ else {
351
+ setSearchFields(ast.name, ast.query);
352
+ for (const child of ast.children)
353
+ applySearch(child, schema, accountability);
354
+ }
355
+ function setSearchFields(collection, query) {
356
+ if (!query.search)
357
+ return;
358
+ // Searching reads the values, so the read permission applies whatever the action the AST is processed for
359
+ const permission = accountability?.permissions?.find((permission) => permission.collection === collection && permission.action === 'read');
360
+ let readableFields = [];
361
+ if (permission?.fields?.includes('*')) {
362
+ readableFields = Object.keys(schema.collections[collection].fields);
363
+ }
364
+ else if (permission?.fields) {
365
+ // Legacy permissions with an empty fields array only give access to the primary key
366
+ readableFields = permission.fields.length > 0 ? permission.fields : [schema.collections[collection].primary];
367
+ }
368
+ query.search.fields = readableFields;
369
+ }
370
+ }
335
371
  }
336
372
  /**
337
373
  * Checks if the provided payload matches the configured permissions, and adds the presets to the payload.
@@ -9,4 +9,8 @@ export declare class MetaService {
9
9
  getMetaForQuery(collection: string, query: any): Promise<Record<string, any> | undefined>;
10
10
  totalCount(collection: string): Promise<number>;
11
11
  filterCount(collection: string, query: Query): Promise<number>;
12
+ /**
13
+ * Go through the same authorization as the read of the items, so that the counts match the returned data
14
+ */
15
+ private getAuthorizedQuery;
12
16
  }
@@ -1,6 +1,7 @@
1
1
  import getDatabase from '../database/index.js';
2
- import { ForbiddenException } from '../exceptions/index.js';
3
2
  import { applyFilter, applySearch } from '../utils/apply-query.js';
3
+ import getASTFromQuery from '../utils/get-ast-from-query.js';
4
+ import { AuthorizationService } from './authorization.js';
4
5
  export class MetaService {
5
6
  knex;
6
7
  accountability;
@@ -30,42 +31,42 @@ export class MetaService {
30
31
  }
31
32
  async totalCount(collection) {
32
33
  const dbQuery = this.knex(collection).count('*', { as: 'count' }).first();
33
- if (this.accountability?.admin !== true) {
34
- const permissionsRecord = this.accountability?.permissions?.find((permission) => {
35
- return permission.action === 'read' && permission.collection === collection;
36
- });
37
- if (!permissionsRecord)
38
- throw new ForbiddenException();
39
- const permissions = permissionsRecord.permissions ?? {};
40
- applyFilter(this.knex, this.schema, dbQuery, permissions, collection, {});
34
+ // Only the permissions apply to the total count, not the filter and search of the query
35
+ const { filter } = await this.getAuthorizedQuery(collection, { fields: [] });
36
+ if (filter && Object.keys(filter).length > 0) {
37
+ applyFilter(this.knex, this.schema, dbQuery, filter, collection, {});
41
38
  }
42
39
  const result = await dbQuery;
43
40
  return Number(result?.count ?? 0);
44
41
  }
45
42
  async filterCount(collection, query) {
46
43
  const dbQuery = this.knex(collection).count('*', { as: 'count' });
47
- let filter = query.filter || {};
48
- if (this.accountability?.admin !== true) {
49
- const permissionsRecord = this.accountability?.permissions?.find((permission) => {
50
- return permission.action === 'read' && permission.collection === collection;
51
- });
52
- if (!permissionsRecord)
53
- throw new ForbiddenException();
54
- const permissions = permissionsRecord.permissions ?? {};
55
- if (Object.keys(filter).length > 0) {
56
- filter = { _and: [permissions, filter] };
57
- }
58
- else {
59
- filter = permissions;
60
- }
61
- }
62
- if (Object.keys(filter).length > 0) {
44
+ const { filter, search } = await this.getAuthorizedQuery(collection, query);
45
+ if (filter && Object.keys(filter).length > 0) {
63
46
  applyFilter(this.knex, this.schema, dbQuery, filter, collection, {});
64
47
  }
65
- if (query.search) {
66
- applySearch(this.schema, dbQuery, query.search, collection);
48
+ if (search) {
49
+ applySearch(this.schema, dbQuery, search, collection);
67
50
  }
68
51
  const records = await dbQuery;
69
52
  return Number(records[0].count);
70
53
  }
54
+ /**
55
+ * Go through the same authorization as the read of the items, so that the counts match the returned data
56
+ */
57
+ async getAuthorizedQuery(collection, query) {
58
+ let ast = await getASTFromQuery(collection, query, this.schema, {
59
+ accountability: this.accountability,
60
+ knex: this.knex,
61
+ });
62
+ if (this.accountability?.admin !== true) {
63
+ const authorizationService = new AuthorizationService({
64
+ accountability: this.accountability,
65
+ knex: this.knex,
66
+ schema: this.schema,
67
+ });
68
+ ast = await authorizationService.processAST(ast);
69
+ }
70
+ return ast.query;
71
+ }
71
72
  }
@@ -478,6 +478,23 @@ export class PayloadService {
478
478
  knex: this.knex,
479
479
  schema: this.schema,
480
480
  });
481
+ const adminItemService = new ItemsService(relation.collection, {
482
+ knex: this.knex,
483
+ schema: this.schema,
484
+ });
485
+ const parentKey = parent || payload[currentPrimaryKeyField];
486
+ // Add the back-reference to the item, unless it is already linked to this parent.
487
+ const attachToParent = async (item) => {
488
+ const key = item[relatedPrimaryKeyField];
489
+ if (key) {
490
+ // readMany rather than readOne: not found is not an error.
491
+ const currentItems = await adminItemService.readMany([key], { fields: [relation.field] });
492
+ if (currentItems.length > 0 && currentItems[0][relation.field] == parentKey) {
493
+ return omit(item, relation.field);
494
+ }
495
+ }
496
+ return { ...item, [relation.field]: parentKey };
497
+ };
481
498
  const recordsToUpsert = [];
482
499
  const savedPrimaryKeys = [];
483
500
  // Nested array of individual items
@@ -512,10 +529,8 @@ export class PayloadService {
512
529
  [relatedPrimaryKeyField]: relatedRecord,
513
530
  };
514
531
  }
515
- recordsToUpsert.push({
516
- ...record,
517
- [relation.field]: parent || payload[currentPrimaryKeyField],
518
- });
532
+ const attachedRecord = await attachToParent(record);
533
+ recordsToUpsert.push(attachedRecord);
519
534
  }
520
535
  savedPrimaryKeys.push(...(await itemsService.upsertMany(recordsToUpsert, {
521
536
  onRevisionCreate: (pk) => revisions.push(pk),
@@ -581,14 +596,14 @@ export class PayloadService {
581
596
  }
582
597
  return {
583
598
  ...record,
584
- [relation.field]: parent || payload[currentPrimaryKeyField],
599
+ [relation.field]: parentKey,
585
600
  };
586
601
  });
587
602
  }
588
603
  else {
589
604
  createPayload = alterations.create.map((item) => ({
590
605
  ...item,
591
- [relation.field]: parent || payload[currentPrimaryKeyField],
606
+ [relation.field]: parentKey,
592
607
  }));
593
608
  }
594
609
  await itemsService.createMany(createPayload, {
@@ -601,10 +616,8 @@ export class PayloadService {
601
616
  if (alterations.update) {
602
617
  const primaryKeyField = this.schema.collections[relation.collection].primary;
603
618
  for (const item of alterations.update) {
604
- await itemsService.updateOne(item[primaryKeyField], {
605
- ...item,
606
- [relation.field]: parent || payload[currentPrimaryKeyField],
607
- }, {
619
+ const attachedItem = await attachToParent(item);
620
+ await itemsService.updateOne(attachedItem[primaryKeyField], attachedItem, {
608
621
  onRevisionCreate: (pk) => revisions.push(pk),
609
622
  bypassEmitAction: (params) => opts?.bypassEmitAction ? opts.bypassEmitAction(params) : nestedActionEvents.push(params),
610
623
  emitEvents: opts?.emitEvents,
@@ -1,9 +1,20 @@
1
1
  import type { Query, Relation } from '@wbce-d9/types';
2
+ /**
3
+ * Search query, along with the fields it is allowed to match. For non-admin users, the fields are empty until the
4
+ * AuthorizationService sets the readable ones
5
+ */
6
+ export type ASTSearch = {
7
+ fields: string[];
8
+ value: string;
9
+ };
10
+ export type ASTQuery = Omit<Query, 'search'> & {
11
+ search?: ASTSearch | null;
12
+ };
2
13
  export type M2ONode = {
3
14
  type: 'm2o';
4
15
  name: string;
5
16
  children: (NestedCollectionNode | FieldNode | FunctionFieldNode)[];
6
- query: Query;
17
+ query: ASTQuery;
7
18
  fieldKey: string;
8
19
  relation: Relation;
9
20
  parentKey: string;
@@ -16,7 +27,7 @@ export type A2MNode = {
16
27
  [collection: string]: (NestedCollectionNode | FieldNode | FunctionFieldNode)[];
17
28
  };
18
29
  query: {
19
- [collection: string]: Query;
30
+ [collection: string]: ASTQuery;
20
31
  };
21
32
  relatedKey: {
22
33
  [collection: string]: string;
@@ -29,7 +40,7 @@ export type O2MNode = {
29
40
  type: 'o2m';
30
41
  name: string;
31
42
  children: (NestedCollectionNode | FieldNode | FunctionFieldNode)[];
32
- query: Query;
43
+ query: ASTQuery;
33
44
  fieldKey: string;
34
45
  relation: Relation;
35
46
  parentKey: string;
@@ -52,5 +63,5 @@ export type AST = {
52
63
  type: 'root';
53
64
  name: string;
54
65
  children: (NestedCollectionNode | FieldNode | FunctionFieldNode)[];
55
- query: Query;
66
+ query: ASTQuery;
56
67
  };
@@ -1,11 +1,12 @@
1
- import type { Aggregate, Filter, Query, SchemaOverview } from '@wbce-d9/types';
1
+ import type { Aggregate, Filter, SchemaOverview } from '@wbce-d9/types';
2
2
  import type { Knex } from 'knex';
3
+ import type { ASTQuery, ASTSearch } from '../types/index.js';
3
4
  import type { AliasMap } from './get-column-path.js';
4
5
  export declare const generateAlias: (size?: number | undefined) => string;
5
6
  /**
6
7
  * Apply the Query to a given Knex query builder instance
7
8
  */
8
- export default function applyQuery(knex: Knex, collection: string, dbQuery: Knex.QueryBuilder, query: Query, schema: SchemaOverview, options?: {
9
+ export default function applyQuery(knex: Knex, collection: string, dbQuery: Knex.QueryBuilder, query: ASTQuery, schema: SchemaOverview, options?: {
9
10
  aliasMap?: AliasMap;
10
11
  isInnerQuery?: boolean;
11
12
  hasMultiRelationalSort?: boolean | undefined;
@@ -31,5 +32,5 @@ export declare function applyFilter(knex: Knex, schema: SchemaOverview, rootQuer
31
32
  hasMultiRelationalFilter: boolean;
32
33
  };
33
34
  export declare function addWhereClauses(knex: Knex, schema: SchemaOverview, dbQuery: Knex.QueryBuilder, filter: Filter, collection: string, aliasMap: AliasMap, logical?: 'and' | 'or'): void;
34
- export declare function applySearch(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, searchQuery: string, collection: string): Promise<void>;
35
+ export declare function applySearch(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, search: ASTSearch, collection: string): Promise<void>;
35
36
  export declare function applyAggregate(schema: SchemaOverview, dbQuery: Knex.QueryBuilder, aggregate: Aggregate, collection: string): void;
@@ -479,24 +479,33 @@ export function addWhereClauses(knex, schema, dbQuery, filter, collection, alias
479
479
  }
480
480
  }
481
481
  }
482
- export async function applySearch(schema, dbQuery, searchQuery, collection) {
483
- const fields = Object.entries(schema.collections[collection].fields);
482
+ export async function applySearch(schema, dbQuery, search, collection) {
483
+ const { fields, value: searchQuery } = search;
484
484
  dbQuery.andWhere(function () {
485
- fields.forEach(([name, field]) => {
486
- if (field.special.includes('conceal'))
485
+ let hasCondition = false;
486
+ fields.forEach((name) => {
487
+ const field = schema.collections[collection].fields[name];
488
+ if (!field || field.special.includes('conceal'))
487
489
  return;
488
490
  if (['text', 'string'].includes(field.type)) {
489
491
  this.orWhereRaw(`LOWER(??) LIKE ?`, [`${collection}.${name}`, `%${searchQuery.toLowerCase()}%`]);
492
+ hasCondition = true;
490
493
  }
491
494
  else if (['bigInteger', 'integer', 'decimal', 'float'].includes(field.type)) {
492
495
  const number = Number(searchQuery);
493
- if (!isNaN(number))
496
+ if (!isNaN(number)) {
494
497
  this.orWhere({ [`${collection}.${name}`]: number });
498
+ hasCondition = true;
499
+ }
495
500
  }
496
501
  else if (field.type === 'uuid' && validate(searchQuery)) {
497
502
  this.orWhere({ [`${collection}.${name}`]: searchQuery });
503
+ hasCondition = true;
498
504
  }
499
505
  });
506
+ // No field can match the search, so no item should be returned
507
+ if (!hasCondition)
508
+ this.whereRaw('1 = 0');
500
509
  });
501
510
  }
502
511
  export function applyAggregate(schema, dbQuery, aggregate, collection) {
@@ -13,12 +13,6 @@ export default async function getASTFromQuery(collection, query, schema, options
13
13
  return permission.action === action;
14
14
  }) ?? []
15
15
  : null;
16
- const ast = {
17
- type: 'root',
18
- name: collection,
19
- query: query,
20
- children: [],
21
- };
22
16
  let fields = ['*'];
23
17
  if (query.fields) {
24
18
  fields = query.fields;
@@ -60,7 +54,13 @@ export default async function getASTFromQuery(collection, query, schema, options
60
54
  if (query.aggregate && Object.keys(query.aggregate).length && !query.group?.[0]) {
61
55
  delete query.sort;
62
56
  }
63
- ast.children = await parseFields(collection, fields, deep);
57
+ const children = await parseFields(collection, fields, deep);
58
+ const ast = {
59
+ type: 'root',
60
+ name: collection,
61
+ query: formatSearch(collection, query),
62
+ children,
63
+ };
64
64
  return ast;
65
65
  async function parseFields(parentCollection, fields, deep) {
66
66
  if (!fields)
@@ -170,7 +170,7 @@ export default async function getASTFromQuery(collection, query, schema, options
170
170
  };
171
171
  for (const relatedCollection of allowedCollections) {
172
172
  child.children[relatedCollection] = await parseFields(relatedCollection, Array.isArray(nestedFields) ? nestedFields : nestedFields[relatedCollection] || [], deep?.[`${fieldKey}:${relatedCollection}`]);
173
- child.query[relatedCollection] = getDeepQuery(deep?.[`${fieldKey}:${relatedCollection}`] || {});
173
+ child.query[relatedCollection] = formatSearch(relatedCollection, getDeepQuery(deep?.[`${fieldKey}:${relatedCollection}`] || {}));
174
174
  child.relatedKey[relatedCollection] = schema.collections[relatedCollection].primary;
175
175
  }
176
176
  }
@@ -189,7 +189,7 @@ export default async function getASTFromQuery(collection, query, schema, options
189
189
  parentKey: schema.collections[parentCollection].primary,
190
190
  relatedKey: schema.collections[relatedCollection].primary,
191
191
  relation: relation,
192
- query: getDeepQuery(deep?.[fieldKey] || {}),
192
+ query: formatSearch(relatedCollection, getDeepQuery(deep?.[fieldKey] || {})),
193
193
  children: await parseFields(relatedCollection, nestedFields, deep?.[fieldKey] || {}),
194
194
  };
195
195
  if (relationType === 'o2m' && !child.query.sort) {
@@ -272,6 +272,17 @@ export default async function getASTFromQuery(collection, query, schema, options
272
272
  }
273
273
  return fields;
274
274
  }
275
+ /**
276
+ * For admins and internal calls, the search of the AST matches all the fields of the collection. For other users, it
277
+ * matches no field until the AuthorizationService sets the ones the role is allowed to read
278
+ */
279
+ function formatSearch(collection, query) {
280
+ const { search, ...rest } = query;
281
+ if (!search)
282
+ return rest;
283
+ const fields = !accountability || accountability.admin === true ? Object.keys(schema.collections[collection].fields) : [];
284
+ return { ...rest, search: { fields, value: search } };
285
+ }
275
286
  function getRelation(collection, field) {
276
287
  const relation = schema.relations.find((relation) => {
277
288
  return ((relation.collection === collection && relation.field === field) ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbce-d9/api",
3
- "version": "12.7.0",
3
+ "version": "12.7.2",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -66,7 +66,7 @@
66
66
  "@rollup/plugin-node-resolve": "15.0.2",
67
67
  "@rollup/plugin-virtual": "3.0.1",
68
68
  "@wbce-d9/format-title": "10.0.0",
69
- "axios": "^1.18.0",
69
+ "axios": "^1.20.0",
70
70
  "chalk": "5.2.0",
71
71
  "commander": "10.0.1",
72
72
  "date-fns": "2.29.3",
@@ -144,20 +144,20 @@
144
144
  "tmp-promise": "3.0.3",
145
145
  "tsx": "3.12.6",
146
146
  "uuid-validate": "0.0.3",
147
- "@wbce-d9/app": "11.2.0",
148
- "@wbce-d9/extensions-sdk": "10.0.5",
149
- "@wbce-d9/schema": "10.0.0",
147
+ "@wbce-d9/constants": "10.0.0",
148
+ "@wbce-d9/exceptions": "10.0.1",
149
+ "@wbce-d9/app": "11.3.0",
150
+ "@wbce-d9/extensions-sdk": "10.1.0",
151
+ "@wbce-d9/storage": "10.0.0",
152
+ "@wbce-d9/storage-driver-azure": "10.0.5",
150
153
  "@wbce-d9/specs": "9.26.5",
151
- "@wbce-d9/storage-driver-azure": "10.0.4",
152
- "@wbce-d9/storage-driver-cloudinary": "10.0.5",
153
- "@wbce-d9/storage-driver-local": "10.0.4",
154
- "@wbce-d9/storage-driver-gcs": "10.0.4",
155
- "@wbce-d9/storage-driver-s3": "10.0.4",
154
+ "@wbce-d9/storage-driver-gcs": "10.0.5",
155
+ "@wbce-d9/schema": "10.0.0",
156
+ "@wbce-d9/storage-driver-local": "10.0.5",
157
+ "@wbce-d9/storage-driver-s3": "10.0.5",
158
+ "@wbce-d9/storage-driver-cloudinary": "10.0.6",
156
159
  "@wbce-d9/update-check": "10.0.4",
157
- "@wbce-d9/storage": "10.0.0",
158
- "@wbce-d9/utils": "10.0.4",
159
- "@wbce-d9/constants": "10.0.0",
160
- "@wbce-d9/exceptions": "10.0.1"
160
+ "@wbce-d9/utils": "10.0.5"
161
161
  },
162
162
  "devDependencies": {
163
163
  "@ngneat/falso": "6.4.0",