diff --git a/packages/agent/src/routes/access/chart.ts b/packages/agent/src/routes/access/chart.ts index 5dc6746874..06e2fa8977 100644 --- a/packages/agent/src/routes/access/chart.ts +++ b/packages/agent/src/routes/access/chart.ts @@ -225,7 +225,11 @@ export default class ChartRoute extends CollectionRoute { if (inverse) { collection = field.foreignCollection; - filter = (await this.getFilter(context)).nest(inverse); + filter = await this.intersectScopes( + context, + (await this.getFilter(context)).nest(inverse), + [{ collectionName: field.foreignCollection }], + ); aggregation = new Aggregation({ operation: body.aggregator, field: body.aggregateFieldName, @@ -240,7 +244,10 @@ export default class ChartRoute extends CollectionRoute { if (origin && target) { collection = field.throughCollection; - filter = (await this.getFilter(context)).nest(origin); + filter = await this.intersectScopes(context, (await this.getFilter(context)).nest(origin), [ + { collectionName: field.throughCollection }, + { collectionName: field.foreignCollection, prefix: target }, + ]); aggregation = new Aggregation({ operation: body.aggregator, field: body.aggregateFieldName ? `${target}:${body.aggregateFieldName}` : null, @@ -318,6 +325,26 @@ export default class ChartRoute extends CollectionRoute { ); } + /** No record of an aggregation can be redacted afterwards, so each aggregated scope filters. */ + private async intersectScopes( + context: Context, + filter: Filter, + scoped: Array<{ collectionName: string; prefix?: string }>, + ): Promise { + const scopes = await Promise.all( + scoped.map(async ({ collectionName, prefix }) => { + const collection = this.dataSource.getCollection(collectionName); + const scope = await this.services.authorization.getScope(collection, context); + + return prefix ? scope?.nest(prefix) : scope; + }), + ); + + return filter.override({ + conditionTree: ConditionTreeFactory.intersect(filter.conditionTree, ...scopes), + }); + } + private async getFilter(context: Context): Promise { const scope = await this.services.authorization.getScope(this.collection, context); diff --git a/packages/agent/src/routes/access/csv-related.ts b/packages/agent/src/routes/access/csv-related.ts index ef5325cfb4..85250334ee 100644 --- a/packages/agent/src/routes/access/csv-related.ts +++ b/packages/agent/src/routes/access/csv-related.ts @@ -41,6 +41,11 @@ export default class CsvRelatedRoute extends RelationRoute { const caller = QueryStringParser.parseCaller(context); const filter = ContextFilterFactory.buildPaginated(this.foreignCollection, context, scope); const parentId = IdUtils.unpackId(this.collection.schema, context.params.parentId); + const relationScopes = await this.services.authorization.getRelationScopes( + context, + this.foreignCollection, + projection, + ); CsvRouteContext.buildResponse(context); @@ -52,7 +57,18 @@ export default class CsvRelatedRoute extends RelationRoute { this.options.limitExportSize, this.foreignCollection, async (cal: Caller, fil: PaginatedFilter, proj: Projection) => - CollectionUtils.listRelation(this.collection, parentId, this.relationName, cal, fil, proj), + relationScopes.redact( + await CollectionUtils.listRelation( + this.collection, + parentId, + this.relationName, + cal, + fil, + relationScopes.withScopeFields(proj), + ), + proj, + cal.timezone, + ), ); context.response.body = Readable.from(gen); } diff --git a/packages/agent/src/routes/access/csv.ts b/packages/agent/src/routes/access/csv.ts index 02eb795465..8c39ba7697 100644 --- a/packages/agent/src/routes/access/csv.ts +++ b/packages/agent/src/routes/access/csv.ts @@ -1,3 +1,4 @@ +import type { Caller, PaginatedFilter, Projection } from '@forestadmin/datasource-toolkit'; import type Router from '@koa/router'; import type { Context } from 'koa'; @@ -31,10 +32,20 @@ export default class CsvRoute extends CollectionRoute { const scope = await this.services.authorization.getScope(this.collection, context); const caller = QueryStringParser.parseCaller(context); const filter = ContextFilterFactory.buildPaginated(this.collection, context, scope); + const relationScopes = await this.services.authorization.getRelationScopes( + context, + this.collection, + projection, + ); CsvRouteContext.buildResponse(context); - const list = this.collection.list.bind(this.collection); + const list = async (cal: Caller, fil: PaginatedFilter, proj: Projection) => + relationScopes.redact( + await this.collection.list(cal, fil, relationScopes.withScopeFields(proj)), + proj, + cal.timezone, + ); const gen = CsvGenerator.generate( caller, diff --git a/packages/agent/src/routes/access/get.ts b/packages/agent/src/routes/access/get.ts index fe189577d8..7bda813f98 100644 --- a/packages/agent/src/routes/access/get.ts +++ b/packages/agent/src/routes/access/get.ts @@ -30,10 +30,17 @@ export default class GetRoute extends CollectionRoute { QueryStringParser.parseProjectionFromHeaderOrQuery(this.collection, context), ); - const records = await this.collection.list( - QueryStringParser.parseCaller(context), - filter, - projection.withPks(this.collection), + const caller = QueryStringParser.parseCaller(context); + const queried = projection.withPks(this.collection); + const relationScopes = await this.services.authorization.getRelationScopes( + context, + this.collection, + queried, + ); + const records = relationScopes.redact( + await this.collection.list(caller, filter, relationScopes.withScopeFields(queried)), + queried, + caller.timezone, ); if (!records.length) { diff --git a/packages/agent/src/routes/access/list-related.ts b/packages/agent/src/routes/access/list-related.ts index f12a17533a..e607b17292 100644 --- a/packages/agent/src/routes/access/list-related.ts +++ b/packages/agent/src/routes/access/list-related.ts @@ -34,13 +34,24 @@ export default class ListRelatedRoute extends RelationRoute { QueryStringParser.parseProjectionFromHeaderOrQuery(this.foreignCollection, context), ); - const records = await CollectionUtils.listRelation( - this.collection, - parentId, - this.relationName, - QueryStringParser.parseCaller(context), - paginatedFilter, - projection.withPks(this.foreignCollection), + const caller = QueryStringParser.parseCaller(context); + const queried = projection.withPks(this.foreignCollection); + const relationScopes = await this.services.authorization.getRelationScopes( + context, + this.foreignCollection, + queried, + ); + const records = relationScopes.redact( + await CollectionUtils.listRelation( + this.collection, + parentId, + this.relationName, + caller, + paginatedFilter, + relationScopes.withScopeFields(queried), + ), + queried, + caller.timezone, ); context.response.body = this.services.serializer.serializeWithSearchMetadata( diff --git a/packages/agent/src/routes/access/list.ts b/packages/agent/src/routes/access/list.ts index 081cd6bb19..94a6779712 100644 --- a/packages/agent/src/routes/access/list.ts +++ b/packages/agent/src/routes/access/list.ts @@ -27,10 +27,17 @@ export default class ListRoute extends CollectionRoute { QueryStringParser.parseProjectionFromHeaderOrQuery(this.collection, context), ); - const records = await this.collection.list( - QueryStringParser.parseCaller(context), - paginatedFilter, - projection.withPks(this.collection), + const caller = QueryStringParser.parseCaller(context); + const queried = projection.withPks(this.collection); + const relationScopes = await this.services.authorization.getRelationScopes( + context, + this.collection, + queried, + ); + const records = relationScopes.redact( + await this.collection.list(caller, paginatedFilter, relationScopes.withScopeFields(queried)), + queried, + caller.timezone, ); context.response.body = this.services.serializer.serializeWithSearchMetadata( diff --git a/packages/agent/src/services/authorization/authorization.ts b/packages/agent/src/services/authorization/authorization.ts index f14bfad821..3e6649affa 100644 --- a/packages/agent/src/services/authorization/authorization.ts +++ b/packages/agent/src/services/authorization/authorization.ts @@ -1,13 +1,21 @@ import type { RequestedProjection } from '../../utils/query-string'; +import type { RelationScope } from '../../utils/relation-scopes'; import type { Collection, CollectionDecorator, ConditionTree, + ManyToOneSchema, + OneToOneSchema, } from '@forestadmin/datasource-toolkit'; import type { ForestAdminClient } from '@forestadmin/forestadmin-client'; import type { Context } from 'koa'; -import { ForbiddenError, Projection, UnprocessableError } from '@forestadmin/datasource-toolkit'; +import { + ForbiddenError, + Projection, + SchemaUtils, + UnprocessableError, +} from '@forestadmin/datasource-toolkit'; import { ChainedSQLQueryError, CollectionActionEvent, @@ -19,6 +27,7 @@ import { HttpCode } from '../../types'; import ConditionTreeParser from '../../utils/condition-tree-parser'; import FieldPathUtils from '../../utils/field-path'; import QueryStringParser from '../../utils/query-string'; +import RelationScopes from '../../utils/relation-scopes'; type FieldUsage = { action: string; path: string; collectionName: string }; @@ -284,6 +293,40 @@ export default class AuthorizationService { return ConditionTreeParser.fromPlainObject(collection, scope); } + /** The caller's scope on each collection the projection reaches through a to-one relation. */ + public async getRelationScopes( + context: Context, + collection: Collection, + projection: Projection, + ): Promise { + return new RelationScopes(await this.collectRelationScopes(context, collection, projection)); + } + + private async collectRelationScopes( + context: Context, + collection: Collection, + projection: Projection, + prefix?: string, + ): Promise { + const scopes = await Promise.all( + Object.entries(projection.relations).map(async ([name, subProjection]) => { + const relation = SchemaUtils.getRelation(collection.schema, name, collection.name); + const foreignCollection = collection.dataSource.getCollection( + (relation as ManyToOneSchema | OneToOneSchema).foreignCollection, + ); + const path = prefix ? `${prefix}:${name}` : name; + const [scope, nested] = await Promise.all([ + this.getScope(foreignCollection, context), + this.collectRelationScopes(context, foreignCollection, subProjection, path), + ]); + + return scope ? [{ path, collection: foreignCollection, scope }, ...nested] : nested; + }), + ); + + return scopes.flat(); + } + public invalidateScopeCache(renderingId: number | string) { this.forestAdminClient.markScopesAsUpdated(renderingId); } diff --git a/packages/agent/src/utils/relation-scopes.ts b/packages/agent/src/utils/relation-scopes.ts new file mode 100644 index 0000000000..fcf932f737 --- /dev/null +++ b/packages/agent/src/utils/relation-scopes.ts @@ -0,0 +1,59 @@ +import type { + Collection, + ConditionTree, + Projection, + RecordData, +} from '@forestadmin/datasource-toolkit'; + +import { RecordUtils } from '@forestadmin/datasource-toolkit'; + +export type RelationScope = { path: string; collection: Collection; scope: ConditionTree }; + +/** + * The scope of a collection reached through a to-one relation empties the related record rather + * than filtering the root rows: a scope on `accounts` restricts which accounts are seen, never + * which `cards` exist. + */ +export default class RelationScopes { + constructor(private readonly scopes: RelationScope[] = []) {} + + withScopeFields(projection: Projection): Projection { + return projection.union(...this.scopes.map(({ path, scope }) => scope.projection.nest(path))); + } + + redact(records: RecordData[], projection: Projection, timezone: string): RecordData[] { + if (!this.scopes.length) return records; + + const outOfScopePaths = records.map(record => this.getOutOfScopePaths(record, timezone)); + const reprojected = projection.apply(records); + + reprojected.forEach((record, index) => { + for (const path of outOfScopePaths[index]) RelationScopes.clear(record, path); + }); + + return reprojected; + } + + private getOutOfScopePaths(record: RecordData, timezone: string): string[] { + return this.scopes + .filter(({ path, collection, scope }) => { + const related = RecordUtils.getFieldValue(record, path) as RecordData; + + return related && !scope.match(related, collection, timezone); + }) + .map(({ path }) => path); + } + + private static clear(record: RecordData, path: string): void { + const index = path.indexOf(':'); + + if (index === -1) { + record[path] = null; + + return; + } + + const related = record[path.substring(0, index)] as RecordData; + if (related) RelationScopes.clear(related, path.substring(index + 1)); + } +} diff --git a/packages/agent/test/security/related-collection-scopes.test.ts b/packages/agent/test/security/related-collection-scopes.test.ts new file mode 100644 index 0000000000..a84c97a28e --- /dev/null +++ b/packages/agent/test/security/related-collection-scopes.test.ts @@ -0,0 +1,338 @@ +import type { DataSource } from '@forestadmin/datasource-toolkit'; +import type { Readable } from 'stream'; + +import { createMockContext } from '@shopify/jest-koa-mocks'; + +import Chart from '../../src/routes/access/chart'; +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 AuthorizationService from '../../src/services/authorization/authorization'; +import * as factories from '../__factories__'; + +describe('scopes on related collections', () => { + const options = factories.forestAdminHttpDriverOptions.build(); + + const buildDataSource = (): DataSource => + factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'cards', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.numericPrimaryKey().build(), + accountId: factories.columnSchema.build({ columnType: 'Number' }), + holderId: factories.columnSchema.build({ columnType: 'Number' }), + 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.numericPrimaryKey().build(), + iban: factories.columnSchema.build({ columnType: 'String' }), + organizationId: factories.columnSchema.build({ columnType: 'Number' }), + organization: factories.manyToOneSchema.build({ + foreignCollection: 'organizations', + foreignKey: 'organizationId', + }), + }, + }), + }), + factories.collection.build({ + name: 'organizations', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.numericPrimaryKey().build(), + name: factories.columnSchema.build({ columnType: 'String' }), + accounts: factories.oneToManySchema.build({ + foreignCollection: 'accounts', + originKey: 'organizationId', + originKeyTarget: 'id', + }), + }, + }), + }), + factories.collection.build({ + name: 'holders', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.numericPrimaryKey().build(), + fullName: factories.columnSchema.build({ columnType: 'String' }), + nationalId: factories.columnSchema.build({ columnType: 'String' }), + region: factories.columnSchema.build({ columnType: 'String' }), + cards: factories.oneToManySchema.build({ + foreignCollection: 'cards', + originKey: 'holderId', + originKeyTarget: 'id', + }), + organizations: factories.manyToManySchema.build({ + foreignCollection: 'organizations', + throughCollection: 'memberships', + originKey: 'holderId', + originKeyTarget: 'id', + foreignKey: 'organizationId', + foreignKeyTarget: 'id', + }), + }, + }), + }), + factories.collection.build({ + name: 'memberships', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.numericPrimaryKey().build(), + holderId: factories.columnSchema.build({ columnType: 'Number' }), + organizationId: factories.columnSchema.build({ columnType: 'Number' }), + status: factories.columnSchema.build({ columnType: 'String' }), + holder: factories.manyToOneSchema.build({ + foreignCollection: 'holders', + foreignKey: 'holderId', + }), + organization: factories.manyToOneSchema.build({ + foreignCollection: 'organizations', + foreignKey: 'organizationId', + }), + }, + }), + }), + ]); + + const scopes = { + accounts: { field: 'organization:name', operator: 'equal', value: 'OwnOrg' }, + holders: { field: 'region', operator: 'equal', value: 'EU' }, + organizations: { field: 'name', operator: 'equal', value: 'OwnOrg' }, + memberships: { field: 'status', operator: 'equal', value: 'active' }, + }; + + const buildServices = () => { + const forestAdminClient = factories.forestAdminClient.build(); + + (forestAdminClient.permissionService.canOnCollection as jest.Mock).mockResolvedValue(true); + (forestAdminClient.getScope as jest.Mock).mockImplementation( + async ({ collectionName }) => scopes[collectionName] ?? null, + ); + + 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 = {}, body?: unknown) => + createMockContext({ + headers, + requestBody: body, + state: { user: { id: 35, renderingId: 42, email: 'operator@domain.com' } }, + customProperties: { + ...customProperties, + query: { timezone: 'Europe/Paris', ...(customProperties.query as object) }, + }, + }); + + const cards = [ + { + id: 1, + account: { id: 10, iban: 'FR76', organization: { name: 'OwnOrg' } }, + holder: { id: 100, nationalId: 'EU-1', region: 'EU' }, + }, + { + id: 2, + account: { id: 20, iban: 'DE22', organization: { name: 'AnotherTenant' } }, + holder: { id: 100, nationalId: 'EU-1', region: 'EU' }, + }, + { + id: 3, + account: { id: 30, iban: 'FR14', organization: { name: 'OwnOrg' } }, + holder: { id: 300, nationalId: 'US-3', region: 'US' }, + }, + { id: 4, account: null, holder: { id: 100, nationalId: 'EU-1', region: 'EU' } }, + ]; + const projectionHeader = { 'forest-projection': 'id,account:iban,holder:nationalId' }; + const exportQuery = { + header: 'Id,Iban', + 'fields[cards]': 'id,account', + 'fields[account]': 'iban', + }; + + const readCsv = async (stream: Readable) => { + let csv = ''; + for await (const chunk of stream) csv += chunk; + + return csv; + }; + + describe('list', () => { + it('should fetch the columns each related scope reads', async () => { + const dataSource = buildDataSource(); + const list = jest.spyOn(dataSource.getCollection('cards'), 'list').mockResolvedValue([]); + + await new List(buildServices(), options, dataSource, 'cards').handleList( + buildContext({}, projectionHeader), + ); + + expect(list).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.arrayContaining(['account:organization:name', 'holder:region']), + ); + }); + + it('should keep every card and empty the related records outside their scope', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + jest.spyOn(dataSource.getCollection('cards'), 'list').mockResolvedValue(cards); + + await new List(services, options, dataSource, 'cards').handleList( + buildContext({}, projectionHeader), + ); + + expect(services.serializer.serializeWithSearchMetadata).toHaveBeenCalledWith( + dataSource.getCollection('cards'), + [ + { id: 1, account: { id: 10, iban: 'FR76' }, holder: { id: 100, nationalId: 'EU-1' } }, + { id: 2, account: null, holder: { id: 100, nationalId: 'EU-1' } }, + { id: 3, account: { id: 30, iban: 'FR14' }, holder: null }, + { id: 4, account: null, holder: { id: 100, nationalId: 'EU-1' } }, + ], + null, + ); + }); + }); + + describe('get', () => { + it('should serve the card with its out-of-scope account emptied', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + jest.spyOn(dataSource.getCollection('cards'), 'list').mockResolvedValue([cards[1]]); + + await new Get(services, options, dataSource, 'cards').handleGet( + buildContext({ params: { id: '2' } }, projectionHeader), + ); + + expect(services.serializer.serialize).toHaveBeenCalledWith( + dataSource.getCollection('cards'), + { id: 2, account: null, holder: { id: 100, nationalId: 'EU-1' } }, + ); + }); + }); + + describe('csv', () => { + it('should leave the cells of an out-of-scope related record empty', async () => { + const dataSource = buildDataSource(); + const context = buildContext({ query: exportQuery }); + jest.spyOn(dataSource.getCollection('cards'), 'list').mockResolvedValue(cards.slice(0, 2)); + + await new Csv(buildServices(), options, dataSource, 'cards').handleCsv(context); + + expect(await readCsv(context.response.body as Readable)).toEqual('Id,Iban\n1,FR76\n2,\n'); + }); + }); + + describe('related routes', () => { + it('should empty the out-of-scope records reached from the related collection', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + jest.spyOn(dataSource.getCollection('cards'), 'list').mockResolvedValue([cards[1]]); + + await new ListRelated(services, options, dataSource, 'holders', 'cards').handleListRelated( + buildContext({ params: { parentId: '100' } }, projectionHeader), + ); + + expect(services.serializer.serializeWithSearchMetadata).toHaveBeenCalledWith( + dataSource.getCollection('cards'), + [{ id: 2, account: null, holder: { id: 100, nationalId: 'EU-1' } }], + null, + ); + }); + + it('should leave the out-of-scope cells empty in a related export', async () => { + const dataSource = buildDataSource(); + const context = buildContext({ params: { parentId: '100' }, query: exportQuery }); + jest.spyOn(dataSource.getCollection('cards'), 'list').mockResolvedValue(cards.slice(0, 2)); + + await new CsvRelated( + buildServices(), + options, + dataSource, + 'holders', + 'cards', + ).handleRelatedCsv(context); + + expect(await readCsv(context.response.body as Readable)).toEqual('Id,Iban\n1,FR76\n2,\n'); + }); + }); + + describe('leaderboard', () => { + const leaderboard = (relationshipFieldName: string, labelFieldName: string) => ({ + type: 'Leaderboard', + aggregator: 'Count', + relationshipFieldName, + labelFieldName, + limit: 5, + }); + + it('should apply the scope of the aggregated one-to-many collection', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const body = leaderboard('accounts', 'name'); + (services.chartHandler.getChartWithContextInjected as jest.Mock).mockResolvedValue(body); + scopes.organizations.value = 'Root'; + const aggregate = jest + .spyOn(dataSource.getCollection('accounts'), 'aggregate') + .mockResolvedValue([]); + + await new Chart(services, options, dataSource, 'organizations').handleChart( + buildContext({}, {}, body), + ); + scopes.organizations.value = 'OwnOrg'; + + expect(aggregate.mock.calls[0][1].conditionTree).toEqual( + expect.objectContaining({ + aggregator: 'And', + conditions: [ + expect.objectContaining({ field: 'organization:name', value: 'Root' }), + expect.objectContaining({ field: 'organization:name', value: 'OwnOrg' }), + ], + }), + ); + }); + + it('should apply the scopes of the through and foreign collections of a many-to-many', async () => { + const dataSource = buildDataSource(); + const services = buildServices(); + const body = leaderboard('organizations', 'fullName'); + (services.chartHandler.getChartWithContextInjected as jest.Mock).mockResolvedValue(body); + const aggregate = jest + .spyOn(dataSource.getCollection('memberships'), 'aggregate') + .mockResolvedValue([]); + + await new Chart(services, options, dataSource, 'holders').handleChart( + buildContext({}, {}, body), + ); + + expect(aggregate.mock.calls[0][1].conditionTree).toEqual( + expect.objectContaining({ + aggregator: 'And', + conditions: [ + expect.objectContaining({ field: 'holder:region', value: 'EU' }), + expect.objectContaining({ field: 'status', value: 'active' }), + expect.objectContaining({ field: 'organization:name', value: 'OwnOrg' }), + ], + }), + ); + }); + }); +}); diff --git a/packages/agent/test/services/authorization/authorization.test.ts b/packages/agent/test/services/authorization/authorization.test.ts index 30ebb5a815..26b9d52e2a 100644 --- a/packages/agent/test/services/authorization/authorization.test.ts +++ b/packages/agent/test/services/authorization/authorization.test.ts @@ -1,6 +1,6 @@ import type { Context } from 'koa'; -import { Projection } from '@forestadmin/datasource-toolkit'; +import { ConditionTreeLeaf, Projection } from '@forestadmin/datasource-toolkit'; import { ChainedSQLQueryError, ChartType, @@ -539,4 +539,113 @@ describe('AuthorizationService', () => { ); }); }); + + describe('getRelationScopes', () => { + const buildDataSource = () => + factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'cards', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + account: factories.manyToOneSchema.build({ foreignCollection: 'accounts' }), + holder: factories.manyToOneSchema.build({ foreignCollection: 'holders' }), + }, + }), + }), + factories.collection.build({ + name: 'accounts', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + iban: factories.columnSchema.build(), + organization: factories.manyToOneSchema.build({ foreignCollection: 'organizations' }), + }, + }), + }), + factories.collection.build({ + name: 'organizations', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + name: factories.columnSchema.build(), + }, + }), + }), + factories.collection.build({ + name: 'holders', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.uuidPrimaryKey().build(), + region: factories.columnSchema.build(), + nationalId: factories.columnSchema.build(), + }, + }), + }), + ]); + const context = { state: { user: { id: 35, renderingId: 42 } } } as unknown as Context; + const scopesByCollection = { + accounts: new ConditionTreeLeaf('organization:name', 'Equal', 'OwnOrg'), + holders: new ConditionTreeLeaf('region', 'Equal', 'EU'), + }; + + beforeEach(() => { + (ConditionTreeParser.fromPlainObject as jest.Mock).mockImplementation( + (collection, scope) => scope, + ); + }); + + it('should fetch the scope of every collection reached through a relation', async () => { + const forestAdminClient = factories.forestAdminClient.build(); + const authorizationService = new AuthorizationService(forestAdminClient); + + await authorizationService.getRelationScopes( + context, + buildDataSource().getCollection('cards'), + new Projection('id', 'account:organization:name', 'holder:nationalId'), + ); + + const fetched = (forestAdminClient.getScope as jest.Mock).mock.calls.map( + ([{ collectionName }]) => collectionName, + ); + expect(fetched.sort()).toEqual(['accounts', 'holders', 'organizations']); + }); + + it('should nest each scope under the relation path that reaches its collection', async () => { + const forestAdminClient = factories.forestAdminClient.build(); + const authorizationService = new AuthorizationService(forestAdminClient); + (forestAdminClient.getScope as jest.Mock).mockImplementation( + async ({ collectionName }) => scopesByCollection[collectionName] ?? null, + ); + + const relationScopes = await authorizationService.getRelationScopes( + context, + buildDataSource().getCollection('cards'), + new Projection('id', 'account:iban', 'holder:nationalId'), + ); + + expect( + relationScopes.withScopeFields(new Projection('id', 'account:iban', 'holder:nationalId')), + ).toEqual([ + 'id', + 'account:iban', + 'holder:nationalId', + 'account:organization:name', + 'holder:region', + ]); + }); + + it('should fetch no scope when the projection reaches no relation', async () => { + const forestAdminClient = factories.forestAdminClient.build(); + const authorizationService = new AuthorizationService(forestAdminClient); + + await authorizationService.getRelationScopes( + context, + buildDataSource().getCollection('cards'), + new Projection('id'), + ); + + expect(forestAdminClient.getScope).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/agent/test/utils/relation-scopes.test.ts b/packages/agent/test/utils/relation-scopes.test.ts new file mode 100644 index 0000000000..21a867555f --- /dev/null +++ b/packages/agent/test/utils/relation-scopes.test.ts @@ -0,0 +1,144 @@ +import { ConditionTreeLeaf, Projection } from '@forestadmin/datasource-toolkit'; + +import RelationScopes from '../../src/utils/relation-scopes'; +import * as factories from '../__factories__'; + +describe('RelationScopes', () => { + const dataSource = factories.dataSource.buildWithCollections([ + factories.collection.build({ + name: 'cards', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.numericPrimaryKey().build(), + accountId: factories.columnSchema.build({ columnType: 'Number' }), + account: factories.manyToOneSchema.build({ + foreignCollection: 'accounts', + foreignKey: 'accountId', + }), + }, + }), + }), + factories.collection.build({ + name: 'accounts', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.numericPrimaryKey().build(), + iban: factories.columnSchema.build({ columnType: 'String' }), + organizationId: factories.columnSchema.build({ columnType: 'Number' }), + organization: factories.manyToOneSchema.build({ + foreignCollection: 'organizations', + foreignKey: 'organizationId', + }), + }, + }), + }), + factories.collection.build({ + name: 'organizations', + schema: factories.collectionSchema.build({ + fields: { + id: factories.columnSchema.numericPrimaryKey().build(), + name: factories.columnSchema.build({ columnType: 'String' }), + tier: factories.columnSchema.build({ columnType: 'String' }), + }, + }), + }), + ]); + const accounts = dataSource.getCollection('accounts'); + const organizations = dataSource.getCollection('organizations'); + const accountScope = { + path: 'account', + collection: accounts, + scope: new ConditionTreeLeaf('organization:name', 'Equal', 'OwnOrg'), + }; + const organizationScope = { + path: 'account:organization', + collection: organizations, + scope: new ConditionTreeLeaf('tier', 'Equal', 'gold'), + }; + + describe('withScopeFields', () => { + test('should add the fields each scope reads, nested under its relation', () => { + const relationScopes = new RelationScopes([accountScope, organizationScope]); + + expect(relationScopes.withScopeFields(new Projection('id', 'account:iban'))).toEqual([ + 'id', + 'account:iban', + 'account:organization:name', + 'account:organization:tier', + ]); + }); + + test('should leave the projection as is when no relation is scoped', () => { + expect(new RelationScopes().withScopeFields(new Projection('id', 'account:iban'))).toEqual([ + 'id', + 'account:iban', + ]); + }); + }); + + describe('redact', () => { + const projection = new Projection('id', 'account:iban'); + + test('should empty a related record outside its scope and keep the root record', () => { + const relationScopes = new RelationScopes([accountScope]); + + const records = relationScopes.redact( + [ + { id: 1, account: { iban: 'FR76', organization: { name: 'OwnOrg' } } }, + { id: 2, account: { iban: 'DE22', organization: { name: 'AnotherTenant' } } }, + ], + projection, + 'Europe/Paris', + ); + + expect(records).toEqual([ + { id: 1, account: { iban: 'FR76' } }, + { id: 2, account: null }, + ]); + }); + + test('should keep a missing related record missing', () => { + const relationScopes = new RelationScopes([accountScope]); + + const records = relationScopes.redact([{ id: 4, account: null }], projection, 'Europe/Paris'); + + expect(records).toEqual([{ id: 4, account: null }]); + }); + + test('should empty a record reached through several relations', () => { + const relationScopes = new RelationScopes([organizationScope]); + + const records = relationScopes.redact( + [ + { id: 1, account: { iban: 'FR76', organization: { name: 'OwnOrg', tier: 'gold' } } }, + { id: 2, account: { iban: 'FR14', organization: { name: 'OwnOrg', tier: 'silver' } } }, + ], + new Projection('id', 'account:iban', 'account:organization:name'), + 'Europe/Paris', + ); + + expect(records).toEqual([ + { id: 1, account: { iban: 'FR76', organization: { name: 'OwnOrg' } } }, + { id: 2, account: { iban: 'FR14', organization: null } }, + ]); + }); + + test('should evaluate a deeper scope on the record before its parent is emptied', () => { + const relationScopes = new RelationScopes([accountScope, organizationScope]); + + const records = relationScopes.redact( + [{ id: 2, account: { iban: 'DE22', organization: { name: 'AnotherTenant', tier: 'x' } } }], + new Projection('id', 'account:iban', 'account:organization:name'), + 'Europe/Paris', + ); + + expect(records).toEqual([{ id: 2, account: null }]); + }); + + test('should return the records untouched when no relation is scoped', () => { + const records = [{ id: 1, account: { iban: 'FR76', extra: true } }]; + + expect(new RelationScopes().redact(records, projection, 'Europe/Paris')).toBe(records); + }); + }); +});