diff --git a/packages/agent/src/routes/access/chart.ts b/packages/agent/src/routes/access/chart.ts index ebea91fc42..90ca1da19b 100644 --- a/packages/agent/src/routes/access/chart.ts +++ b/packages/agent/src/routes/access/chart.ts @@ -1,5 +1,6 @@ import type { Caller, + Collection, ConditionTreeBranch, DateOperation, Filter, @@ -29,6 +30,7 @@ import { DateTime } from 'luxon'; import { v1 as uuidv1 } from 'uuid'; import ContextFilterFactory from '../../utils/context-filter-factory'; +import FieldPathUtils from '../../utils/field-path'; import QueryStringParser from '../../utils/query-string'; import CollectionRoute from '../collection-route'; @@ -67,6 +69,11 @@ export default class ChartRoute extends CollectionRoute { chartRequest, }); + await this.services.authorization.assertCanReadQueryFields(context, this.collection, [ + 'filter', + 'search', + ]); + switch (chartRequest.type) { case ChartType.Value: return this.makeValueChart(context); @@ -120,6 +127,11 @@ export default class ChartRoute extends CollectionRoute { aggregateFieldName: aggregateField, } = context.request.body; + await this.assertCanReadAggregatedFields(context, this.collection, [ + ['group a chart by', groupByField], + ['aggregate a chart on', aggregateField], + ]); + const rows = await this.collection.aggregate( QueryStringParser.parseCaller(context), await this.getFilter(context), @@ -144,6 +156,11 @@ export default class ChartRoute extends CollectionRoute { timeRange, } = context.request.body; + await this.assertCanReadAggregatedFields(context, this.collection, [ + ['group a chart by', groupByDateField], + ['aggregate a chart on', aggregateField], + ]); + const filter = await this.getFilter(context); const filterOnlyWithValues = filter.override({ conditionTree: ConditionTreeFactory.intersect( @@ -233,9 +250,25 @@ export default class ChartRoute extends CollectionRoute { } if (collection && filter && aggregation) { - const rows = await this.dataSource - .getCollection(collection) - .aggregate(QueryStringParser.parseCaller(context), filter, aggregation, Number(body.limit)); + const aggregatedCollection = this.dataSource.getCollection(collection); + + await this.assertCanReadAggregatedFields(context, aggregatedCollection, [ + ['group a leaderboard by', aggregation.groups[0].field], + ['aggregate a leaderboard on', aggregation.field], + ]); + + // A count exposes the cardinality of the relation, which `/relationships//count` puts + // behind `browse`. + if (!aggregation.field) { + await this.services.authorization.assertCanBrowse(context, field.foreignCollection); + } + + const rows = await aggregatedCollection.aggregate( + QueryStringParser.parseCaller(context), + filter, + aggregation, + Number(body.limit), + ); return rows.map(row => ({ key: row.group[aggregation.groups[0].field] as string, @@ -254,6 +287,10 @@ export default class ChartRoute extends CollectionRoute { ); const aggregation = new Aggregation({ operation: aggregator, field: aggregateField }); + await this.assertCanReadAggregatedFields(context, this.collection, [ + ['aggregate a chart on', aggregateField], + ]); + const rows = await this.collection.aggregate( QueryStringParser.parseCaller(context), filter, @@ -263,6 +300,24 @@ export default class ChartRoute extends CollectionRoute { return rows.length ? (rows[0].value as number) : 0; } + private async assertCanReadAggregatedFields( + context: Context, + pathCollection: Collection, + fields: Array<[action: string, path: string]>, + ): Promise { + await this.services.authorization.assertCanReadUsages( + context, + this.collection.name, + fields + .filter(([, path]) => path) + .map(([action, path]) => ({ + action, + path, + collectionName: FieldPathUtils.getLeafCollection(pathCollection, path).name, + })), + ); + } + private async getFilter(context: Context): Promise { const scope = await this.services.authorization.getScope(this.collection, context); diff --git a/packages/agent/src/routes/access/count-related.ts b/packages/agent/src/routes/access/count-related.ts index e52f4413e3..3d0a45d80d 100644 --- a/packages/agent/src/routes/access/count-related.ts +++ b/packages/agent/src/routes/access/count-related.ts @@ -20,6 +20,11 @@ export default class CountRelatedRoute extends RelationRoute { await this.services.authorization.assertCanBrowse(context, this.foreignCollection.name); if (this.foreignCollection.schema.countable) { + await this.services.authorization.assertCanReadQueryFields(context, this.foreignCollection, [ + 'filter', + 'search', + ]); + const parentId = IdUtils.unpackId(this.collection.schema, context.params.parentId); const scope = await this.services.authorization.getScope(this.foreignCollection, context); const caller = QueryStringParser.parseCaller(context); diff --git a/packages/agent/src/routes/access/count.ts b/packages/agent/src/routes/access/count.ts index b312b440b9..a6802e043e 100644 --- a/packages/agent/src/routes/access/count.ts +++ b/packages/agent/src/routes/access/count.ts @@ -16,6 +16,11 @@ export default class CountRoute extends CollectionRoute { await this.services.authorization.assertCanBrowse(context, this.collection.name); if (this.collection.schema.countable) { + await this.services.authorization.assertCanReadQueryFields(context, this.collection, [ + 'filter', + 'search', + ]); + const scope = await this.services.authorization.getScope(this.collection, context); const caller = QueryStringParser.parseCaller(context); let filter = ContextFilterFactory.build(this.collection, context, scope); diff --git a/packages/agent/src/routes/access/csv-related.ts b/packages/agent/src/routes/access/csv-related.ts index b25699877a..ef5325cfb4 100644 --- a/packages/agent/src/routes/access/csv-related.ts +++ b/packages/agent/src/routes/access/csv-related.ts @@ -23,13 +23,20 @@ export default class CsvRelatedRoute extends RelationRoute { async handleRelatedCsv(context: Context): Promise { await this.services.authorization.assertCanBrowse(context, this.foreignCollection.name); await this.services.authorization.assertCanExport(context, this.foreignCollection.name); + await this.services.authorization.assertCanReadQueryFields(context, this.foreignCollection); - const { header } = context.request.query as Record; + const { header: requestedHeader } = context.request.query as Record; - const projection = QueryStringParser.parseProjectionFromHeaderOrQuery( + const requested = QueryStringParser.parseProjectionFromHeaderOrQuery( this.foreignCollection, context, ); + const projection = await this.services.authorization.redactProjection( + context, + this.foreignCollection, + requested, + ); + const header = CsvGenerator.filterHeader(requestedHeader, requested.projection, projection); const scope = await this.services.authorization.getScope(this.foreignCollection, context); const caller = QueryStringParser.parseCaller(context); const filter = ContextFilterFactory.buildPaginated(this.foreignCollection, context, scope); diff --git a/packages/agent/src/routes/access/csv.ts b/packages/agent/src/routes/access/csv.ts index 47fd5f685c..02eb795465 100644 --- a/packages/agent/src/routes/access/csv.ts +++ b/packages/agent/src/routes/access/csv.ts @@ -17,10 +17,17 @@ export default class CsvRoute extends CollectionRoute { async handleCsv(context: Context): Promise { await this.services.authorization.assertCanBrowse(context, this.collection.name); await this.services.authorization.assertCanExport(context, this.collection.name); + await this.services.authorization.assertCanReadQueryFields(context, this.collection); - const { header } = context.request.query as Record; + const { header: requestedHeader } = context.request.query as Record; - const projection = QueryStringParser.parseProjectionFromHeaderOrQuery(this.collection, context); + const requested = QueryStringParser.parseProjectionFromHeaderOrQuery(this.collection, context); + const projection = await this.services.authorization.redactProjection( + context, + this.collection, + requested, + ); + const header = CsvGenerator.filterHeader(requestedHeader, requested.projection, projection); const scope = await this.services.authorization.getScope(this.collection, context); const caller = QueryStringParser.parseCaller(context); const filter = ContextFilterFactory.buildPaginated(this.collection, context, scope); diff --git a/packages/agent/src/routes/access/get.ts b/packages/agent/src/routes/access/get.ts index 99a64defd0..fe189577d8 100644 --- a/packages/agent/src/routes/access/get.ts +++ b/packages/agent/src/routes/access/get.ts @@ -24,7 +24,11 @@ export default class GetRoute extends CollectionRoute { ), }); - const projection = QueryStringParser.parseProjectionFromHeaderOrQuery(this.collection, context); + const projection = await this.services.authorization.redactProjection( + context, + this.collection, + QueryStringParser.parseProjectionFromHeaderOrQuery(this.collection, context), + ); const records = await this.collection.list( QueryStringParser.parseCaller(context), diff --git a/packages/agent/src/routes/access/list-related.ts b/packages/agent/src/routes/access/list-related.ts index 9e0c0e0781..f12a17533a 100644 --- a/packages/agent/src/routes/access/list-related.ts +++ b/packages/agent/src/routes/access/list-related.ts @@ -18,6 +18,7 @@ export default class ListRelatedRoute extends RelationRoute { public async handleListRelated(context: Context): Promise { await this.services.authorization.assertCanBrowse(context, this.foreignCollection.name); + await this.services.authorization.assertCanReadQueryFields(context, this.foreignCollection); const parentId = IdUtils.unpackId(this.collection.schema, context.params.parentId); const scope = await this.services.authorization.getScope(this.foreignCollection, context); @@ -27,9 +28,10 @@ export default class ListRelatedRoute extends RelationRoute { scope, ); - const projection = QueryStringParser.parseProjectionFromHeaderOrQuery( - this.foreignCollection, + const projection = await this.services.authorization.redactProjection( context, + this.foreignCollection, + QueryStringParser.parseProjectionFromHeaderOrQuery(this.foreignCollection, context), ); const records = await CollectionUtils.listRelation( diff --git a/packages/agent/src/routes/access/list.ts b/packages/agent/src/routes/access/list.ts index 47aa5d6237..081cd6bb19 100644 --- a/packages/agent/src/routes/access/list.ts +++ b/packages/agent/src/routes/access/list.ts @@ -12,6 +12,7 @@ export default class ListRoute extends CollectionRoute { public async handleList(context: Context) { await this.services.authorization.assertCanBrowse(context, this.collection.name); + await this.services.authorization.assertCanReadQueryFields(context, this.collection); const scope = await this.services.authorization.getScope(this.collection, context); let paginatedFilter = ContextFilterFactory.buildPaginated(this.collection, context, scope); @@ -20,7 +21,11 @@ export default class ListRoute extends CollectionRoute { paginatedFilter, ); - const projection = QueryStringParser.parseProjectionFromHeaderOrQuery(this.collection, context); + const projection = await this.services.authorization.redactProjection( + context, + this.collection, + QueryStringParser.parseProjectionFromHeaderOrQuery(this.collection, context), + ); const records = await this.collection.list( QueryStringParser.parseCaller(context), diff --git a/packages/agent/src/routes/modification/update.ts b/packages/agent/src/routes/modification/update.ts index a19f832218..2c86ba0119 100644 --- a/packages/agent/src/routes/modification/update.ts +++ b/packages/agent/src/routes/modification/update.ts @@ -40,7 +40,10 @@ export default class UpdateRoute extends CollectionRoute { const [updateResult] = await this.collection.list( caller, new Filter({ conditionTree }), - ProjectionFactory.all(this.collection), + await this.services.authorization.redactProjection(context, this.collection, { + projection: ProjectionFactory.all(this.collection), + namedByCaller: false, + }), ); context.response.body = this.services.serializer.serialize(this.collection, updateResult); diff --git a/packages/agent/src/services/authorization/authorization.ts b/packages/agent/src/services/authorization/authorization.ts index e7a9ac8a28..fa4ac5a93f 100644 --- a/packages/agent/src/services/authorization/authorization.ts +++ b/packages/agent/src/services/authorization/authorization.ts @@ -1,8 +1,13 @@ -import type { Collection, ConditionTree } from '@forestadmin/datasource-toolkit'; +import type { RequestedProjection } from '../../utils/query-string'; +import type { + Collection, + CollectionDecorator, + ConditionTree, +} from '@forestadmin/datasource-toolkit'; import type { ForestAdminClient } from '@forestadmin/forestadmin-client'; import type { Context } from 'koa'; -import { UnprocessableError } from '@forestadmin/datasource-toolkit'; +import { ForbiddenError, Projection, UnprocessableError } from '@forestadmin/datasource-toolkit'; import { ChainedSQLQueryError, CollectionActionEvent, @@ -12,6 +17,14 @@ import { import { HttpCode } from '../../types'; import ConditionTreeParser from '../../utils/condition-tree-parser'; +import FieldPathUtils from '../../utils/field-path'; +import QueryStringParser from '../../utils/query-string'; + +type FieldUsage = { action: string; path: string; collectionName: string }; + +export type QueryComponent = 'filter' | 'sort' | 'search'; + +const ALL_QUERY_COMPONENTS: QueryComponent[] = ['filter', 'sort', 'search']; export default class AuthorizationService { constructor(private readonly forestAdminClient: ForestAdminClient) {} @@ -40,6 +53,135 @@ export default class AuthorizationService { await this.assertCanOnCollection(CollectionActionEvent.Export, context, collectionName); } + public async canRead(context: Context, collectionName: string): Promise { + return this.forestAdminClient.permissionService.canOnCollection({ + userId: context.state.user.id, + event: CollectionActionEvent.Read, + collectionName, + }); + } + + /** + * An unnamed field is redacted rather than refused: `ProjectionFactory.all` expands every column + * of every to-one relation when no `fields[]` is sent, so refusing would turn an ordinary + * listing into a 403. + */ + public async redactProjection( + context: Context, + collection: Collection, + requested: RequestedProjection, + ): Promise { + const { projection, namedByCaller } = requested; + const owners = projection.map(path => FieldPathUtils.getLeafCollection(collection, path).name); + const permissions = await this.getReadPermissions(context, collection.name, owners); + const isReadable = (index: number) => permissions.get(owners[index]); + + if (namedByCaller) { + const denied = projection + .map((field, index) => ({ field, collection: owners[index] })) + .filter((_, index) => !isReadable(index)); + + if (denied.length) { + const fields = denied + .map(({ field, collection: name }) => `'${field}' from the '${name}' collection`) + .join(', '); + + throw new ForbiddenError(`You are not allowed to read ${fields}.`); + } + } + + return new Projection(...projection.filter((_, index) => isReadable(index))); + } + + /** + * Refused rather than redacted: dropping a condition widens the result set and dropping a sort + * clause silently reorders it, while both leak the value they touch anyway — a `starts_with` + * filter answers one guess per request without returning a column of its own. + * + * `consumes` names the query components the calling route applies to its filter. Checking one it + * drops would refuse a request the denied field cannot reach: `ContextFilterFactory.build` carries + * no sort, which only `buildPaginated` adds. + */ + public async assertCanReadQueryFields( + context: Context, + collection: Collection, + consumes: QueryComponent[] = ALL_QUERY_COMPONENTS, + ): Promise { + const usages: FieldUsage[] = []; + const push = (action: string, path: string) => + usages.push({ + action, + path, + collectionName: FieldPathUtils.getLeafCollection(collection, path).name, + }); + + if (consumes.includes('filter')) { + QueryStringParser.parseConditionTree(collection, context)?.forEachLeaf(leaf => + push('filter on', leaf.field), + ); + } + + if (consumes.includes('sort')) { + for (const { field } of QueryStringParser.parseSort(collection, context)) { + push('sort on', field); + } + } + + const search = consumes.includes('search') + ? QueryStringParser.parseSearch(collection, context) + : null; + + if (search) { + // Asked whatever the extended flag: `relation.column:term` is end-user syntax and reaches a + // relation without it. An unknown answer serves the request — the `replaceSearch` exemption. + const searched = (collection as CollectionDecorator).getSearchedFields?.( + search, + QueryStringParser.parseSearchExtended(context), + ); + + for (const { path, collection: name } of searched ?? []) { + usages.push({ action: 'search on', path, collectionName: name }); + } + } + + await this.assertCanReadUsages(context, collection.name, usages); + } + + public async assertCanReadUsages( + context: Context, + rootCollectionName: string, + usages: FieldUsage[], + ): Promise { + const permissions = await this.getReadPermissions( + context, + rootCollectionName, + usages.map(usage => usage.collectionName), + ); + const denied = usages.find(usage => !permissions.get(usage.collectionName)); + + if (denied) { + throw new ForbiddenError( + `You cannot ${denied.action} '${denied.path}': you are not allowed to read the ` + + `'${denied.collectionName}' collection.`, + ); + } + } + + /** The root is skipped: `browse` gates a listing, `read` a get, and the signed hash a chart. */ + private async getReadPermissions( + context: Context, + rootCollectionName: string, + collectionNames: string[], + ): Promise> { + const toCheck = [...new Set(collectionNames)].filter(name => name !== rootCollectionName); + const allowed = await Promise.all(toCheck.map(name => this.canRead(context, name))); + + return new Map([ + [rootCollectionName, true], + ...toCheck.map((name, index): [string, boolean] => [name, allowed[index]]), + ]); + } + private async assertCanOnCollection( event: CollectionActionEvent, context: Context, diff --git a/packages/agent/src/utils/csv-generator.ts b/packages/agent/src/utils/csv-generator.ts index 9e0e30f5c9..3adfefb57f 100644 --- a/packages/agent/src/utils/csv-generator.ts +++ b/packages/agent/src/utils/csv-generator.ts @@ -52,6 +52,16 @@ export default class CsvGenerator { } } + /** Labels are positionally aligned with the requested projection; dropping one shifts the rest. */ + static filterHeader(header: string, requested: Projection, kept: Projection): string { + if (!header || kept.length === requested.length) return header; + + return header + .split(',') + .filter((_, index) => kept.includes(requested[index])) + .join(','); + } + private static convert(records: RecordData[], projection: Projection): Promise { return writeToString( records.map(record => diff --git a/packages/agent/src/utils/field-path.ts b/packages/agent/src/utils/field-path.ts new file mode 100644 index 0000000000..c496489e43 --- /dev/null +++ b/packages/agent/src/utils/field-path.ts @@ -0,0 +1,22 @@ +import type { Collection } from '@forestadmin/datasource-toolkit'; + +import { SchemaUtils } from '@forestadmin/datasource-toolkit'; + +export default class FieldPathUtils { + static getLeafCollection(collection: Collection, path: string): Collection { + const index = path.indexOf(':'); + + if (index === -1) return collection; + + const relation = SchemaUtils.getRelation( + collection.schema, + path.substring(0, index), + collection.name, + ); + + return FieldPathUtils.getLeafCollection( + collection.dataSource.getCollection(relation.foreignCollection), + path.substring(index + 1), + ); + } +} diff --git a/packages/agent/src/utils/query-string.ts b/packages/agent/src/utils/query-string.ts index 0c77ad76f4..08a046530e 100644 --- a/packages/agent/src/utils/query-string.ts +++ b/packages/agent/src/utils/query-string.ts @@ -21,6 +21,8 @@ import { getRequestId } from './correlation-id'; const DEFAULT_ITEMS_PER_PAGE = 15; const DEFAULT_PAGE_TO_SKIP = 1; +export type RequestedProjection = { projection: Projection; namedByCaller: boolean }; + export default class QueryStringParser { private static VALID_TIMEZONES = new Set(); @@ -86,11 +88,20 @@ export default class QueryStringParser { } } - static parseProjectionFromHeaderOrQuery(collection: Collection, context: Context): Projection { - return ( - QueryStringParser.parseProjectionFromHeader(collection, context) ?? - QueryStringParser.parseProjection(collection, context) - ); + static parseProjectionFromHeaderOrQuery( + collection: Collection, + context: Context, + ): RequestedProjection { + const fromHeader = QueryStringParser.parseProjectionFromHeader(collection, context); + + if (fromHeader) return { projection: fromHeader, namedByCaller: true }; + + const fields = context.request.query[`fields[${collection.name}]`]; + + return { + projection: QueryStringParser.parseProjection(collection, context), + namedByCaller: fields !== '' && fields !== undefined, + }; } static parseSearch(collection: Collection, context: Context): string { diff --git a/packages/agent/test/__factories__/authorization/authorization.ts b/packages/agent/test/__factories__/authorization/authorization.ts index a92742a7b1..321ae86312 100644 --- a/packages/agent/test/__factories__/authorization/authorization.ts +++ b/packages/agent/test/__factories__/authorization/authorization.ts @@ -15,6 +15,12 @@ export class AuthorizationsFactory extends Factory { Authorizations.getScope = jest.fn(); Authorizations.assertCanExecuteChart = jest.fn(); Authorizations.invalidateScopeCache = jest.fn(); + Authorizations.canRead = jest.fn().mockResolvedValue(true); + Authorizations.assertCanReadQueryFields = jest.fn(); + Authorizations.assertCanReadUsages = jest.fn(); + Authorizations.redactProjection = jest + .fn() + .mockImplementation(async (context, collection, requested) => requested.projection); }); } } diff --git a/packages/agent/test/security/related-read-permissions.test.ts b/packages/agent/test/security/related-read-permissions.test.ts new file mode 100644 index 0000000000..991f83d25c --- /dev/null +++ b/packages/agent/test/security/related-read-permissions.test.ts @@ -0,0 +1,621 @@ +import type { CollectionDecorator, DataSource } from '@forestadmin/datasource-toolkit'; + +import { CollectionActionEvent } from '@forestadmin/forestadmin-client'; +import { createMockContext } from '@shopify/jest-koa-mocks'; + +import Chart from '../../src/routes/access/chart'; +import Count from '../../src/routes/access/count'; +import CountRelated from '../../src/routes/access/count-related'; +import Csv from '../../src/routes/access/csv'; +import CsvRelated from '../../src/routes/access/csv-related'; +import Get from '../../src/routes/access/get'; +import List from '../../src/routes/access/list'; +import ListRelated from '../../src/routes/access/list-related'; +import Update from '../../src/routes/modification/update'; +import AuthorizationService from '../../src/services/authorization/authorization'; +import * as factories from '../__factories__'; + +describe('read permissions on related collections', () => { + const options = factories.forestAdminHttpDriverOptions.build(); + + const buildDataSource = (): DataSource => + factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'cards', + schema: factories.collectionSchema.build({ + searchable: true, + countable: true, + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + panLast4: factories.columnSchema.build({ columnType: 'String' }), + accountId: factories.columnSchema.build({ columnType: 'Uuid' }), + holderId: factories.columnSchema.build({ columnType: 'Uuid' }), + account: factories.manyToOneSchema.build({ + foreignCollection: 'accounts', + foreignKey: 'accountId', + }), + holder: factories.manyToOneSchema.build({ + foreignCollection: 'holders', + foreignKey: 'holderId', + }), + }, + }), + }), + factories.collection.build({ + name: 'accounts', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + iban: factories.columnSchema.text().build(), + balance: factories.columnSchema.build({ columnType: 'Number' }), + organizationId: factories.columnSchema.build({ columnType: 'Uuid' }), + organization: factories.manyToOneSchema.build({ + foreignCollection: 'organizations', + foreignKey: 'organizationId', + }), + }, + }), + }), + factories.collection.build({ + name: 'organizations', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + name: factories.columnSchema.build({ columnType: 'String' }), + }, + }), + }), + factories.collection.build({ + name: 'holders', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + fullName: factories.columnSchema.text().build(), + nationalId: factories.columnSchema.text().build(), + cards: factories.oneToManySchema.build({ + foreignCollection: 'cards', + originKey: 'holderId', + originKeyTarget: 'id', + }), + }, + }), + }), + ]); + + const buildServices = (readableCollections: string[] = []) => { + const forestAdminClient = factories.forestAdminClient.build(); + + (forestAdminClient.permissionService.canOnCollection as jest.Mock).mockImplementation( + ({ event, collectionName }) => + collectionName === 'cards' || + (event === CollectionActionEvent.Read && readableCollections.includes(collectionName)), + ); + + const services = factories.forestAdminHttpDriverServices.build(); + services.authorization = new AuthorizationService(forestAdminClient); + services.serializer.serialize = jest.fn(); + services.serializer.serializeWithSearchMetadata = jest.fn(); + + return services; + }; + + const buildContext = ( + customProperties: Record, + headers = {}, + requestBody?: unknown, + ) => + createMockContext({ + headers, + requestBody, + state: { user: { id: 35, renderingId: 42, email: 'operator@domain.com' } }, + customProperties: { + ...customProperties, + query: { timezone: 'Europe/Paris', ...(customProperties.query as object) }, + }, + }); + + describe('projection the caller named', () => { + it('should refuse the request and name every offending field at once', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const list = jest.spyOn(dataSource.getCollection('cards'), 'list').mockResolvedValue([]); + + await expect( + new List(services, options, dataSource, 'cards').handleList( + buildContext({}, { 'forest-projection': 'id,holder:nationalId,account:iban' }), + ), + ).rejects.toThrow( + "You are not allowed to read 'holder:nationalId' from the 'holders' collection, " + + "'account:iban' from the 'accounts' collection.", + ); + + expect(list).not.toHaveBeenCalled(); + }); + + it('should refuse a named field on a get-one too', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + + await expect( + new Get(services, options, dataSource, 'cards').handleGet( + buildContext( + { params: { id: '2d162303-78bf-599e-b197-93590ac3d315' } }, + { 'forest-projection': 'id,holder:fullName' }, + ), + ), + ).rejects.toThrow("You are not allowed to read 'holder:fullName' from the 'holders'"); + }); + + it('should refuse fields named through the fields[] query params', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + + await expect( + new List(services, options, dataSource, 'cards').handleList( + buildContext({ + query: { 'fields[cards]': 'id,holder', 'fields[holder]': 'nationalId' }, + }), + ), + ).rejects.toThrow("You are not allowed to read 'holder:nationalId' from the 'holders'"); + }); + + it('should serve a named field on a collection the caller can read', async () => { + const dataSource = buildDataSource(); + const services = buildServices(['holders']); + const list = jest.spyOn(dataSource.getCollection('cards'), 'list').mockResolvedValue([]); + + await new List(services, options, dataSource, 'cards').handleList( + buildContext({}, { 'forest-projection': 'id,holder:nationalId' }), + ); + + expect([...list.mock.calls[0][2]].sort()).toEqual(['holder:id', 'holder:nationalId', 'id']); + }); + + it('should traverse a collection it cannot read to reach a column it can', async () => { + const dataSource = buildDataSource(); + const services = buildServices(['organizations']); + const list = jest.spyOn(dataSource.getCollection('cards'), 'list').mockResolvedValue([]); + + await new List(services, options, dataSource, 'cards').handleList( + buildContext({}, { 'forest-projection': 'id,account:organization:name' }), + ); + + // `withPks` runs after the check and re-adds a key per surviving relation, so `account:id` + // comes back from a collection the caller cannot read. It carries nothing new only here, + // where the ManyToOne targets the primary key and the row already holds it as + // `cards.accountId`. A OneToOne, or a `foreignKeyTarget` that is not the primary key, + // exposes a key the row does not carry. + expect([...list.mock.calls[0][2]].sort()).toEqual([ + 'account:id', + 'account:organization:id', + 'account:organization:name', + 'id', + ]); + }); + }); + + describe('projection the caller never asked for', () => { + it('should redact rather than refuse, so an ordinary listing keeps working', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const list = jest.spyOn(dataSource.getCollection('cards'), 'list').mockResolvedValue([]); + + await new List(services, options, dataSource, 'cards').handleList(buildContext({})); + + expect(list.mock.calls[0][2].sort()).toEqual(['accountId', 'holderId', 'id', 'panLast4']); + }); + }); + + describe('filter', () => { + const oracleFilter = JSON.stringify({ + field: 'holder:nationalId', + operator: 'starts_with', + value: '1850', + }); + + it('should refuse a filter reading a collection the caller cannot read', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const list = jest.spyOn(dataSource.getCollection('cards'), 'list').mockResolvedValue([]); + + await expect( + new List(services, options, dataSource, 'cards').handleList( + buildContext({ query: { filters: oracleFilter } }), + ), + ).rejects.toThrow( + "You cannot filter on 'holder:nationalId': you are not allowed to read the " + + "'holders' collection.", + ); + + expect(list).not.toHaveBeenCalled(); + }); + + it('should refuse the same filter on the count route', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const aggregate = jest.spyOn(dataSource.getCollection('cards'), 'aggregate'); + + await expect( + new Count(services, options, dataSource, 'cards').handleCount( + buildContext({ query: { filters: oracleFilter } }), + ), + ).rejects.toThrow("you are not allowed to read the 'holders' collection"); + + expect(aggregate).not.toHaveBeenCalled(); + }); + + it('should accept a filter reading a collection the caller can read', async () => { + const dataSource = buildDataSource(); + const services = buildServices(['holders']); + const list = jest.spyOn(dataSource.getCollection('cards'), 'list').mockResolvedValue([]); + + await new List(services, options, dataSource, 'cards').handleList( + buildContext({ query: { filters: oracleFilter } }), + ); + + expect(list.mock.calls[0][1].conditionTree).toMatchObject({ + field: 'holder:nationalId', + operator: 'StartsWith', + value: '1850', + }); + }); + + it('should not check the scope, which the agent injects rather than the caller', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const list = jest.spyOn(dataSource.getCollection('cards'), 'list').mockResolvedValue([]); + + jest + .spyOn(services.authorization, 'getScope') + .mockResolvedValue( + factories.conditionTreeLeaf.build({ field: 'holder:nationalId', value: '1850' }), + ); + + await new List(services, options, dataSource, 'cards').handleList(buildContext({})); + + expect(list.mock.calls[0][1].conditionTree).toMatchObject({ + field: 'holder:nationalId', + value: '1850', + }); + }); + }); + + describe('sort', () => { + // A count applies no sort — `ContextFilterFactory.build` carries none, only `buildPaginated` + // does — so refusing one would refuse a request the denied field cannot reach. + it('should ignore a sort on the count route, which never applies one', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const aggregate = jest + .spyOn(dataSource.getCollection('cards'), 'aggregate') + .mockResolvedValue([{ value: 3, group: {} }]); + + await new Count(services, options, dataSource, 'cards').handleCount( + buildContext({ query: { sort: '-holder.nationalId' } }), + ); + + expect(aggregate).toHaveBeenCalled(); + }); + + it('should refuse a sort reading a collection the caller cannot read', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + + await expect( + new List(services, options, dataSource, 'cards').handleList( + buildContext({ query: { sort: '-account.balance' } }), + ), + ).rejects.toThrow( + "You cannot sort on 'account:balance': you are not allowed to read the " + + "'accounts' collection.", + ); + }); + }); + + describe('extended search', () => { + it('should refuse whatever the stack says the search will reach', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const cards = dataSource.getCollection('cards') as CollectionDecorator; + const aggregate = jest.spyOn(cards, 'aggregate'); + + cards.getSearchedFields = () => [{ path: 'holder:nationalId', collection: 'holders' }]; + + await expect( + new Count(services, options, dataSource, 'cards').handleCount( + buildContext({ query: { search: 'holder.nationalId:1850' } }), + ), + ).rejects.toThrow( + "You cannot search on 'holder:nationalId': you are not allowed to read the " + + "'holders' collection.", + ); + + expect(aggregate).not.toHaveBeenCalled(); + }); + + it('should ask the stack with the extended flag the caller sent', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const cards = dataSource.getCollection('cards') as CollectionDecorator; + const getSearchedFields = jest.fn().mockReturnValue([]); + cards.getSearchedFields = getSearchedFields; + jest.spyOn(cards, 'list').mockResolvedValue([]); + + await new List(services, options, dataSource, 'cards').handleList( + buildContext({ query: { search: 'martin', searchExtended: '1' } }), + ); + + expect(getSearchedFields).toHaveBeenCalledWith('martin', true); + }); + + it('should serve the request when the stack cannot say what a search reaches', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const cards = dataSource.getCollection('cards') as CollectionDecorator; + const list = jest.spyOn(cards, 'list').mockResolvedValue([]); + + // A replaced search: the handler picks the fields, the caller only supplies the text. + cards.getSearchedFields = () => null; + + await new List(services, options, dataSource, 'cards').handleList( + buildContext({ query: { search: 'martin', searchExtended: '1' } }), + ); + + expect(list.mock.calls[0][1]).toMatchObject({ search: 'martin', searchExtended: true }); + }); + + it('should accept a plain search, which never leaves the root collection', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const cards = dataSource.getCollection('cards') as CollectionDecorator; + const list = jest.spyOn(cards, 'list').mockResolvedValue([]); + + cards.getSearchedFields = () => [{ path: 'panLast4', collection: 'cards' }]; + + await new List(services, options, dataSource, 'cards').handleList( + buildContext({ query: { search: 'martin' } }), + ); + + expect(list.mock.calls[0][1]).toMatchObject({ search: 'martin', searchExtended: false }); + }); + + it('should accept an extended search once every to-one relation is readable', async () => { + const dataSource = buildDataSource(); + const services = buildServices(['holders', 'accounts']); + const cards = dataSource.getCollection('cards') as CollectionDecorator; + const list = jest.spyOn(cards, 'list').mockResolvedValue([]); + + cards.getSearchedFields = () => [ + { path: 'panLast4', collection: 'cards' }, + { path: 'holder:fullName', collection: 'holders' }, + { path: 'account:iban', collection: 'accounts' }, + ]; + + await new List(services, options, dataSource, 'cards').handleList( + buildContext({ query: { search: 'martin', searchExtended: '1' } }), + ); + + expect(list.mock.calls[0][1]).toMatchObject({ search: 'martin', searchExtended: true }); + }); + }); + + describe('csv export', () => { + it('should refuse an export naming a column of an unreadable collection', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + + const context = buildContext({ + query: { + header: 'Id,Holder national id', + 'fields[cards]': 'id,holder', + 'fields[holder]': 'nationalId', + }, + }); + + await expect( + new Csv(services, options, dataSource, 'cards').handleCsv(context), + ).rejects.toThrow("You are not allowed to read 'holder:nationalId' from the 'holders'"); + }); + }); + + // `edit` is the entry point here, not `read`: the route re-lists the row it just wrote with a + // projection the caller never supplied, so the same disclosure is one HTTP verb away. + describe('update', () => { + const updateContext = () => + buildContext( + { params: { id: '2d162303-78bf-599e-b197-93590ac3d315' } }, + {}, + { + data: { + id: '2d162303-78bf-599e-b197-93590ac3d315', + attributes: { panLast4: '4242' }, + }, + }, + ); + + it('should redact the record it serializes back rather than refusing the write', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const cards = dataSource.getCollection('cards'); + cards.update = jest.fn(); + const list = jest.spyOn(cards, 'list').mockResolvedValue([{ id: 'a-card' }]); + + await new Update(services, options, dataSource, 'cards').handleUpdate(updateContext()); + + expect([...list.mock.calls[0][2]].sort()).toEqual([ + 'accountId', + 'holderId', + 'id', + 'panLast4', + ]); + }); + + it('should keep the columns of a collection the caller may read', async () => { + const dataSource = buildDataSource(); + const services = buildServices(['holders']); + const cards = dataSource.getCollection('cards'); + cards.update = jest.fn(); + const list = jest.spyOn(cards, 'list').mockResolvedValue([{ id: 'a-card' }]); + + await new Update(services, options, dataSource, 'cards').handleUpdate(updateContext()); + + expect([...list.mock.calls[0][2]].sort()).toEqual([ + 'accountId', + 'holder:fullName', + 'holder:id', + 'holder:nationalId', + 'holderId', + 'id', + 'panLast4', + ]); + }); + }); + + // The related routes are the only sites resolving against `foreignCollection` rather than the + // route's own collection, so a swap between the two would otherwise go unnoticed. + describe('related routes', () => { + const holderCardsContext = () => + buildContext({ + params: { parentId: '2d162303-78bf-599e-b197-93590ac3d315' }, + query: { + filters: JSON.stringify({ + field: 'account:iban', + operator: 'equal', + value: 'FR76', + }), + }, + }); + + it('should refuse a filter on a denied collection reached from the related list', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + + await expect( + new ListRelated(services, options, dataSource, 'holders', 'cards').handleListRelated( + holderCardsContext(), + ), + ).rejects.toThrow( + "You cannot filter on 'account:iban': you are not allowed to read the " + + "'accounts' collection.", + ); + }); + + it('should refuse the same filter on the related export', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + + await expect( + new CsvRelated(services, options, dataSource, 'holders', 'cards').handleRelatedCsv( + holderCardsContext(), + ), + ).rejects.toThrow("you are not allowed to read the 'accounts' collection"); + }); + + it('should refuse the same filter on the related count', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + + await expect( + new CountRelated(services, options, dataSource, 'holders', 'cards').handleCountRelated( + holderCardsContext(), + ), + ).rejects.toThrow("you are not allowed to read the 'accounts' collection"); + }); + + it('should redact the related list projection rather than refuse it', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const list = jest.spyOn(dataSource.getCollection('cards'), 'list').mockResolvedValue([]); + + await new ListRelated(services, options, dataSource, 'holders', 'cards').handleListRelated( + buildContext({ params: { parentId: '2d162303-78bf-599e-b197-93590ac3d315' } }), + ); + + expect(list.mock.calls[0][2].sort()).toEqual(['accountId', 'holderId', 'id', 'panLast4']); + }); + }); + + describe('chart', () => { + it('should refuse to group a chart by a column of an unreadable collection', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const body = { + type: 'Pie', + aggregator: 'Count', + groupByFieldName: 'holder:fullName', + }; + + (services.chartHandler.getChartWithContextInjected as jest.Mock).mockResolvedValue(body); + + await expect( + new Chart(services, options, dataSource, 'cards').handleChart(buildContext({}, {}, body)), + ).rejects.toThrow( + "You cannot group a chart by 'holder:fullName': you are not allowed to read the " + + "'holders' collection.", + ); + }); + + it('should refuse a line chart grouped by a column of an unreadable collection', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const body = { + type: 'Line', + aggregator: 'Count', + groupByFieldName: 'holder:fullName', + timeRange: 'Day', + }; + + (services.chartHandler.getChartWithContextInjected as jest.Mock).mockResolvedValue(body); + + await expect( + new Chart(services, options, dataSource, 'cards').handleChart(buildContext({}, {}, body)), + ).rejects.toThrow( + "You cannot group a chart by 'holder:fullName': you are not allowed to read the " + + "'holders' collection.", + ); + }); + + it('should refuse a value chart aggregating a column of an unreadable collection', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const body = { type: 'Value', aggregator: 'Sum', aggregateFieldName: 'account:balance' }; + + (services.chartHandler.getChartWithContextInjected as jest.Mock).mockResolvedValue(body); + + await expect( + new Chart(services, options, dataSource, 'cards').handleChart(buildContext({}, {}, body)), + ).rejects.toThrow( + "You cannot aggregate a chart on 'account:balance': you are not allowed to read the " + + "'accounts' collection.", + ); + }); + + it('should assert browse on the collection a leaderboard counts, which no field names', async () => { + const dataSource = buildDataSource(); + const forestAdminClient = factories.forestAdminClient.build(); + const services = factories.forestAdminHttpDriverServices.build(); + services.authorization = new AuthorizationService(forestAdminClient); + const body = { + type: 'Leaderboard', + aggregator: 'Count', + relationshipFieldName: 'cards', + labelFieldName: 'fullName', + limit: 5, + }; + + (forestAdminClient.permissionService.canOnCollection as jest.Mock).mockResolvedValue(true); + (services.chartHandler.getChartWithContextInjected as jest.Mock).mockResolvedValue(body); + jest.spyOn(dataSource.getCollection('cards'), 'aggregate').mockResolvedValue([]); + + await new Chart(services, options, dataSource, 'holders').handleChart( + buildContext({}, {}, body), + ); + + expect(forestAdminClient.permissionService.canOnCollection).toHaveBeenCalledWith({ + userId: 35, + event: CollectionActionEvent.Browse, + collectionName: 'cards', + }); + }); + }); +}); diff --git a/packages/agent/test/services/authorization/authorization.test.ts b/packages/agent/test/services/authorization/authorization.test.ts index f11182aebd..30ebb5a815 100644 --- a/packages/agent/test/services/authorization/authorization.test.ts +++ b/packages/agent/test/services/authorization/authorization.test.ts @@ -1,5 +1,6 @@ import type { Context } from 'koa'; +import { Projection } from '@forestadmin/datasource-toolkit'; import { ChainedSQLQueryError, ChartType, @@ -433,4 +434,109 @@ describe('AuthorizationService', () => { expect(forestAdminClient.markScopesAsUpdated).toHaveBeenCalledWith(42); }); }); + + describe('redactProjection', () => { + const buildDataSource = () => + factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'cards', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + holderId: factories.columnSchema.build({ columnType: 'Uuid' }), + holder: factories.manyToOneSchema.build({ + foreignCollection: 'holders', + foreignKey: 'holderId', + }), + }, + }), + }), + factories.collection.build({ + name: 'holders', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + fullName: factories.columnSchema.build(), + nationalId: factories.columnSchema.build(), + }, + }), + }), + ]); + + const context = { + state: { user: { id: 35, renderingId: 42 } }, + } as unknown as Context; + + it('should spend no permission check on the root collection', async () => { + const forestAdminClient = factories.forestAdminClient.build(); + const authorizationService = new AuthorizationService(forestAdminClient); + + const projection = await authorizationService.redactProjection( + context, + buildDataSource().getCollection('cards'), + { projection: new Projection('id', 'holderId'), namedByCaller: true }, + ); + + expect(projection).toEqual(['id', 'holderId']); + expect(forestAdminClient.permissionService.canOnCollection).not.toHaveBeenCalled(); + }); + + it('should check a traversed collection once, whatever the number of paths', async () => { + const forestAdminClient = factories.forestAdminClient.build(); + const authorizationService = new AuthorizationService(forestAdminClient); + + (forestAdminClient.permissionService.canOnCollection as jest.Mock).mockResolvedValue(true); + + await authorizationService.redactProjection( + context, + buildDataSource().getCollection('cards'), + { + projection: new Projection('id', 'holder:fullName', 'holder:nationalId'), + namedByCaller: true, + }, + ); + + expect(forestAdminClient.permissionService.canOnCollection).toHaveBeenCalledTimes(1); + expect(forestAdminClient.permissionService.canOnCollection).toHaveBeenCalledWith({ + userId: 35, + event: CollectionActionEvent.Read, + collectionName: 'holders', + }); + }); + + it('should drop every path reaching a denied collection', async () => { + const forestAdminClient = factories.forestAdminClient.build(); + const authorizationService = new AuthorizationService(forestAdminClient); + + (forestAdminClient.permissionService.canOnCollection as jest.Mock).mockResolvedValue(false); + + const projection = await authorizationService.redactProjection( + context, + buildDataSource().getCollection('cards'), + { + projection: new Projection('id', 'holder:fullName', 'holder:nationalId'), + namedByCaller: false, + }, + ); + + expect(projection).toEqual(['id']); + }); + + it('should refuse instead of redacting when the caller named the fields', async () => { + const forestAdminClient = factories.forestAdminClient.build(); + const authorizationService = new AuthorizationService(forestAdminClient); + + (forestAdminClient.permissionService.canOnCollection as jest.Mock).mockResolvedValue(false); + + await expect( + authorizationService.redactProjection(context, buildDataSource().getCollection('cards'), { + projection: new Projection('id', 'holder:fullName', 'holder:nationalId'), + namedByCaller: true, + }), + ).rejects.toThrow( + "You are not allowed to read 'holder:fullName' from the 'holders' collection, " + + "'holder:nationalId' from the 'holders' collection.", + ); + }); + }); }); diff --git a/packages/agent/test/utils/csv-generator.test.ts b/packages/agent/test/utils/csv-generator.test.ts index a12471f975..06df6bf64f 100644 --- a/packages/agent/test/utils/csv-generator.test.ts +++ b/packages/agent/test/utils/csv-generator.test.ts @@ -409,4 +409,48 @@ describe('CsvGenerator', () => { }); }); }); + + describe('filterHeader', () => { + it('should drop the labels of the paths that were pruned', () => { + const header = CsvGenerator.filterHeader( + 'Id,Pan,Holder national id', + new Projection('id', 'panLast4', 'holder:nationalId'), + new Projection('id', 'panLast4'), + ); + + expect(header).toEqual('Id,Pan'); + }); + + it('should drop a label from the middle without shifting the others', () => { + const header = CsvGenerator.filterHeader( + 'Id,Holder national id,Pan', + new Projection('id', 'holder:nationalId', 'panLast4'), + new Projection('id', 'panLast4'), + ); + + expect(header).toEqual('Id,Pan'); + }); + + it('should return the header untouched when nothing was pruned', () => { + const projection = new Projection('id', 'panLast4'); + + expect(CsvGenerator.filterHeader('Id,Pan', projection, projection)).toEqual('Id,Pan'); + }); + + it('should return an absent header as is', () => { + expect( + CsvGenerator.filterHeader(undefined, new Projection('id', 'x:y'), new Projection('id')), + ).toBeUndefined(); + }); + + it('should drop every label when every path was pruned', () => { + const header = CsvGenerator.filterHeader( + 'Holder national id', + new Projection('holder:nationalId'), + new Projection(), + ); + + expect(header).toEqual(''); + }); + }); }); diff --git a/packages/agent/test/utils/field-path.test.ts b/packages/agent/test/utils/field-path.test.ts new file mode 100644 index 0000000000..62c5cfd5c8 --- /dev/null +++ b/packages/agent/test/utils/field-path.test.ts @@ -0,0 +1,80 @@ +import FieldPathUtils from '../../src/utils/field-path'; +import * as factories from '../__factories__'; + +describe('FieldPathUtils', () => { + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'cards', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + panLast4: factories.columnSchema.build({ columnType: 'String' }), + accountId: factories.columnSchema.build({ columnType: 'Uuid' }), + account: factories.manyToOneSchema.build({ + foreignCollection: 'accounts', + foreignKey: 'accountId', + }), + }, + }), + }), + factories.collection.build({ + name: 'accounts', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + iban: factories.columnSchema.build({ columnType: 'String' }), + organizationId: factories.columnSchema.build({ columnType: 'Uuid' }), + organization: factories.manyToOneSchema.build({ + foreignCollection: 'organizations', + foreignKey: 'organizationId', + }), + }, + }), + }), + factories.collection.build({ + name: 'organizations', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + name: factories.columnSchema.build({ columnType: 'String' }), + }, + }), + }), + ]); + + describe('getLeafCollection', () => { + it('should return the collection itself for a plain column', () => { + const owner = FieldPathUtils.getLeafCollection(dataSource.getCollection('cards'), 'panLast4'); + + expect(owner.name).toEqual('cards'); + }); + + it('should return the collection the relation points to for a one hop path', () => { + const owner = FieldPathUtils.getLeafCollection( + dataSource.getCollection('cards'), + 'account:iban', + ); + + expect(owner.name).toEqual('accounts'); + }); + + it('should return the last collection of the path, not the ones crossed on the way', () => { + const owner = FieldPathUtils.getLeafCollection( + dataSource.getCollection('cards'), + 'account:organization:name', + ); + + expect(owner.name).toEqual('organizations'); + }); + + it('should return the root collection for a self referencing relation', () => { + const cards = dataSource.getCollection('cards'); + cards.schema.fields.parent = factories.manyToOneSchema.build({ + foreignCollection: 'cards', + foreignKey: 'id', + }); + + expect(FieldPathUtils.getLeafCollection(cards, 'parent:panLast4').name).toEqual('cards'); + }); + }); +}); diff --git a/packages/agent/test/utils/query-string.test.ts b/packages/agent/test/utils/query-string.test.ts index 4c890bc326..d19022f457 100644 --- a/packages/agent/test/utils/query-string.test.ts +++ b/packages/agent/test/utils/query-string.test.ts @@ -430,7 +430,7 @@ describe('QueryStringParser', () => { context, ); - expect(projection).toEqual(new Projection('name')); + expect(projection).toEqual({ projection: new Projection('name'), namedByCaller: true }); }); test('should fallback to the query string when the header is missing', () => { @@ -443,7 +443,7 @@ describe('QueryStringParser', () => { context, ); - expect(projection).toEqual(new Projection('name')); + expect(projection).toEqual({ projection: new Projection('name'), namedByCaller: true }); }); test('should fallback to the query string when the header is empty', () => { @@ -457,7 +457,31 @@ describe('QueryStringParser', () => { context, ); - expect(projection).toEqual(new Projection('name')); + expect(projection).toEqual({ projection: new Projection('name'), namedByCaller: true }); + }); + + test('should report the default expansion as not named by the caller', () => { + const context = createMockContext({ customProperties: { query: {} } }); + + const { namedByCaller } = QueryStringParser.parseProjectionFromHeaderOrQuery( + collectionSimple, + context, + ); + + expect(namedByCaller).toBe(false); + }); + + test('should report an empty fields query param as not named by the caller', () => { + const context = createMockContext({ + customProperties: { query: { 'fields[books]': '' } }, + }); + + const { namedByCaller } = QueryStringParser.parseProjectionFromHeaderOrQuery( + collectionSimple, + context, + ); + + expect(namedByCaller).toBe(false); }); test('should throw on an invalid header instead of falling back to the query string', () => { diff --git a/packages/datasource-customizer/src/decorators/search/collection.ts b/packages/datasource-customizer/src/decorators/search/collection.ts index 4e5d8f0df3..6f015edb3c 100644 --- a/packages/datasource-customizer/src/decorators/search/collection.ts +++ b/packages/datasource-customizer/src/decorators/search/collection.ts @@ -9,12 +9,13 @@ import type { DataSourceDecorator, PaginatedFilter, PlainConditionTree, + SearchedField, } from '@forestadmin/datasource-toolkit'; import { CollectionDecorator, ConditionTreeFactory } from '@forestadmin/datasource-toolkit'; import CollectionSearchContext from './collection-search-context'; -import normalizeName from './normalize-name'; +import { getLeafCollectionName, getSearchedFieldPaths, lenientGetSchema } from './field-paths'; import { extractSpecifiedFields, generateConditionTree, parseQuery } from './parse-query'; export default class SearchCollectionDecorator extends CollectionDecorator { @@ -91,7 +92,7 @@ export default class SearchCollectionDecorator extends CollectionDecorator { [ ...defaultFields, ...[...specifiedFields, ...(options?.onlyFields ?? []), ...(options?.includeFields ?? [])] - .map(name => this.lenientGetSchema(name)) + .map(name => lenientGetSchema(this, name)) .filter(Boolean) .map(schema => [schema.field, schema.schema] as [string, ColumnSchema]), ] @@ -118,6 +119,25 @@ export default class SearchCollectionDecorator extends CollectionDecorator { return conditionTree?.toPlainObject(); } + /** + * Answers against `childCollection`, which is what the search actually reads — a field hidden by + * the publication or renaming layers above is still searched. Returns `null` when a replacer is + * installed: the customer's handler chooses the fields, and the caller only supplies the text. + */ + override getSearchedFields(search: string, extended: boolean): SearchedField[] | null { + if (this.replacer) return null; + + const paths = [ + ...getSearchedFieldPaths(this.childCollection, search), + ...this.getFields(this.childCollection, extended).map(([path]) => path), + ]; + + return paths.map(path => ({ + path, + collection: getLeafCollectionName(this.childCollection, path), + })); + } + private getFields(collection: Collection, extended: boolean): [string, ColumnSchema][] { const fields: [string, ColumnSchema][] = []; @@ -135,31 +155,4 @@ export default class SearchCollectionDecorator extends CollectionDecorator { return fields; } - - private lenientGetSchema(path: string): { field: string; schema: ColumnSchema } | null { - const [prefix, suffix] = path.split(/:(.*)/); - const fuzzyPrefix = normalizeName(prefix); - - for (const [field, schema] of Object.entries(this.schema.fields)) { - const fuzzyFieldName = normalizeName(field); - - if (fuzzyPrefix === fuzzyFieldName) { - if (!suffix && schema.type === 'Column') { - return { field, schema }; - } - - if ( - suffix && - (schema.type === 'OneToMany' || schema.type === 'ManyToOne' || schema.type === 'OneToOne') - ) { - const related = this.dataSource.getCollection(schema.foreignCollection); - const fuzzy = related.lenientGetSchema(suffix); - - if (fuzzy) return { field: `${field}:${fuzzy.field}`, schema: fuzzy.schema }; - } - } - } - - return null; - } } diff --git a/packages/datasource-customizer/src/decorators/search/field-paths.ts b/packages/datasource-customizer/src/decorators/search/field-paths.ts new file mode 100644 index 0000000000..92c38c2d7f --- /dev/null +++ b/packages/datasource-customizer/src/decorators/search/field-paths.ts @@ -0,0 +1,76 @@ +import type { + Collection, + ColumnSchema, + FieldSchema, + RelationSchema, +} from '@forestadmin/datasource-toolkit'; + +import { SchemaUtils } from '@forestadmin/datasource-toolkit'; + +import normalizeName from './normalize-name'; +import { extractSpecifiedFields, parseQuery } from './parse-query'; + +const SEARCHABLE_THROUGH = ['OneToMany', 'ManyToOne', 'OneToOne']; + +const isSearchableThrough = (schema: FieldSchema): schema is RelationSchema => + SEARCHABLE_THROUGH.includes(schema.type); + +export function lenientGetSchema( + collection: Collection, + path: string, +): { field: string; schema: ColumnSchema } | null { + const [prefix, suffix] = path.split(/:(.*)/); + const fuzzyPrefix = normalizeName(prefix); + const matches = Object.entries(collection.schema.fields).filter( + ([field]) => fuzzyPrefix === normalizeName(field), + ); + + if (!suffix) { + const column = matches.find(([, schema]) => schema.type === 'Column'); + + return column ? { field: column[0], schema: column[1] as ColumnSchema } : null; + } + + for (const [field, schema] of matches) { + if (isSearchableThrough(schema)) { + const related = collection.dataSource.getCollection(schema.foreignCollection); + const fuzzy = lenientGetSchema(related, suffix); + + if (fuzzy) return { field: `${field}:${fuzzy.field}`, schema: fuzzy.schema }; + } + } + + return null; +} + +/** + * The field paths a search string reaches through the `relation.column:term` syntax, resolved the + * same way the search decorator resolves them — fuzzily, and across to-many relations too. + */ +export function getSearchedFieldPaths(collection: Collection, search: string): string[] { + return extractSpecifiedFields(parseQuery(search)) + .map(name => lenientGetSchema(collection, name)?.field) + .filter(Boolean); +} + +/** + * The collection a path's last column belongs to — the one a read permission applies to. A prefix + * that names no relation throws rather than falling back to `collection`, which the caller pins to + * readable: an unresolvable path must not read as an allowed one. + */ +export function getLeafCollectionName(collection: Collection, path: string): string { + const index = path.indexOf(':'); + + if (index === -1) return collection.name; + + const relation = SchemaUtils.getRelation( + collection.schema, + path.substring(0, index), + collection.name, + ); + + return getLeafCollectionName( + collection.dataSource.getCollection(relation.foreignCollection), + path.substring(index + 1), + ); +} diff --git a/packages/datasource-customizer/src/index.ts b/packages/datasource-customizer/src/index.ts index 3de392f5c5..d0fc11b126 100644 --- a/packages/datasource-customizer/src/index.ts +++ b/packages/datasource-customizer/src/index.ts @@ -13,6 +13,7 @@ export { default as CollectionChartContext } from './decorators/chart/context'; export { ComputedDefinition } from './decorators/computed/types'; export { OperatorDefinition } from './decorators/operators-emulate/types'; export { RelationDefinition } from './decorators/relation/types'; +export { getSearchedFieldPaths } from './decorators/search/field-paths'; export { SearchDefinition } from './decorators/search/types'; export { SegmentDefinition } from './decorators/segment/types'; export * from './decorators/write/write-replace/types'; diff --git a/packages/datasource-customizer/test/decorators/search/collections.test.ts b/packages/datasource-customizer/test/decorators/search/collections.test.ts index 54e261bca6..b9f5b910ac 100644 --- a/packages/datasource-customizer/test/decorators/search/collections.test.ts +++ b/packages/datasource-customizer/test/decorators/search/collections.test.ts @@ -749,3 +749,56 @@ describe('SearchCollectionDecorator', () => { }); }); }); + +describe('getSearchedFields', () => { + const buildCards = () => + buildCollection( + { + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + panLast4: factories.columnSchema.build({ columnType: 'String' }), + holderId: factories.columnSchema.build({ columnType: 'Uuid' }), + holder: factories.manyToOneSchema.build({ + foreignCollection: 'holders', + foreignKey: 'holderId', + }), + }, + }, + [ + factories.collection.build({ + name: 'holders', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + nationalId: factories.columnSchema.build({ columnType: 'String' }), + }, + }), + }), + ], + ); + + test('it names the relation column the dot syntax reaches, without extended search', () => { + const searched = buildCards().getSearchedFields('holder.nationalId:1850', false); + + expect(searched).toContainEqual({ path: 'holder:nationalId', collection: 'holders' }); + }); + + test('it names every to-one column an extended search reaches', () => { + const searched = buildCards().getSearchedFields('martin', true); + + expect(searched).toContainEqual({ path: 'holder:nationalId', collection: 'holders' }); + }); + + test('it names none of them when the search is not extended', () => { + const searched = buildCards().getSearchedFields('martin', false); + + expect(searched.every(({ collection }) => collection !== 'holders')).toBe(true); + }); + + test('it says it cannot tell when a replacer picks the fields', () => { + const decorator = buildCards(); + decorator.replaceSearch(value => ({ field: 'id', operator: 'Equal', value })); + + expect(decorator.getSearchedFields('martin', true)).toBeNull(); + }); +}); diff --git a/packages/datasource-customizer/test/decorators/search/field-paths.test.ts b/packages/datasource-customizer/test/decorators/search/field-paths.test.ts new file mode 100644 index 0000000000..2757fc6b82 --- /dev/null +++ b/packages/datasource-customizer/test/decorators/search/field-paths.test.ts @@ -0,0 +1,60 @@ +import * as factories from '@forestadmin/datasource-toolkit/dist/test/__factories__'; + +import { getLeafCollectionName } from '../../../src/decorators/search/field-paths'; + +describe('getLeafCollectionName', () => { + const buildDataSource = () => + factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'cards', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + panLast4: factories.columnSchema.build({ columnType: 'String' }), + holderId: factories.columnSchema.build({ columnType: 'Uuid' }), + holder: factories.manyToOneSchema.build({ + foreignCollection: 'holders', + foreignKey: 'holderId', + }), + }, + }), + }), + factories.collection.build({ + name: 'holders', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + nationalId: factories.columnSchema.text().build(), + }, + }), + }), + ]); + + it('should return the collection owning the last column of the path', () => { + const cards = buildDataSource().getCollection('cards'); + + expect(getLeafCollectionName(cards, 'holder:nationalId')).toBe('holders'); + }); + + it('should return the collection itself for one of its own columns', () => { + const cards = buildDataSource().getCollection('cards'); + + expect(getLeafCollectionName(cards, 'panLast4')).toBe('cards'); + }); + + // The caller pins the collection it asked about to readable, so answering `cards` here would + // turn "this path does not resolve" into "this path is allowed". + it('should throw rather than fall back to the collection it was asked about', () => { + const cards = buildDataSource().getCollection('cards'); + + expect(() => getLeafCollectionName(cards, 'panLast4:nationalId')).toThrow( + /relation.*panLast4|panLast4.*not/i, + ); + }); + + it('should throw when the prefix names nothing at all', () => { + const cards = buildDataSource().getCollection('cards'); + + expect(() => getLeafCollectionName(cards, 'unknown:nationalId')).toThrow(); + }); +}); diff --git a/packages/datasource-toolkit/src/decorators/collection-decorator.ts b/packages/datasource-toolkit/src/decorators/collection-decorator.ts index 4747989b5e..40528d7855 100644 --- a/packages/datasource-toolkit/src/decorators/collection-decorator.ts +++ b/packages/datasource-toolkit/src/decorators/collection-decorator.ts @@ -10,6 +10,8 @@ import type Projection from '../interfaces/query/projection'; import type { CompositeId, RecordData } from '../interfaces/record'; import type { CollectionSchema } from '../interfaces/schema'; +export type SearchedField = { path: string; collection: string }; + export default class CollectionDecorator implements Collection { readonly dataSource: DataSource; protected childCollection: Collection; @@ -34,6 +36,16 @@ export default class CollectionDecorator implements Collection { return this.childCollection.name; } + /** + * Which fields a search will actually reach, and where they live — `null` when the collection + * cannot say, which a caller must read as "unknown", never as "none". + */ + getSearchedFields(search: string, extended: boolean): SearchedField[] | null { + return this.childCollection instanceof CollectionDecorator + ? this.childCollection.getSearchedFields(search, extended) + : null; + } + constructor(childCollection: Collection, dataSource: DataSource) { this.childCollection = childCollection; this.dataSource = dataSource; diff --git a/packages/datasource-toolkit/src/index.ts b/packages/datasource-toolkit/src/index.ts index 97048ff3e8..7f1e499b40 100644 --- a/packages/datasource-toolkit/src/index.ts +++ b/packages/datasource-toolkit/src/index.ts @@ -7,7 +7,7 @@ export { MAP_ALLOWED_OPERATORS_FOR_COLUMN_TYPE as allowedOperatorsForColumnType export { default as BaseCollection } from './base-collection'; export { default as BaseDataSource } from './base-datasource'; export { default as DataSourceDecorator } from './decorators/datasource-decorator'; -export { default as CollectionDecorator } from './decorators/collection-decorator'; +export { default as CollectionDecorator, SearchedField } from './decorators/collection-decorator'; // Query Interface export { default as Aggregation } from './interfaces/query/aggregation';