Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions packages/agent/src/routes/access/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<Filter> {
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<Filter> {
const scope = await this.services.authorization.getScope(this.collection, context);

Expand Down
18 changes: 17 additions & 1 deletion packages/agent/src/routes/access/csv-related.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);
}
Expand Down
13 changes: 12 additions & 1 deletion packages/agent/src/routes/access/csv.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Caller, PaginatedFilter, Projection } from '@forestadmin/datasource-toolkit';
import type Router from '@koa/router';
import type { Context } from 'koa';

Expand Down Expand Up @@ -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,
Expand Down
15 changes: 11 additions & 4 deletions packages/agent/src/routes/access/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
25 changes: 18 additions & 7 deletions packages/agent/src/routes/access/list-related.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 11 additions & 4 deletions packages/agent/src/routes/access/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
45 changes: 44 additions & 1 deletion packages/agent/src/services/authorization/authorization.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 };

Expand Down Expand Up @@ -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<RelationScopes> {
return new RelationScopes(await this.collectRelationScopes(context, collection, projection));
}

private async collectRelationScopes(
context: Context,
collection: Collection,
projection: Projection,
prefix?: string,
): Promise<RelationScope[]> {
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);
}
Expand Down
59 changes: 59 additions & 0 deletions packages/agent/src/utils/relation-scopes.ts
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading
Loading