Skip to content
Merged
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
4 changes: 2 additions & 2 deletions packages/agent/src/routes/access/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,8 @@ export default class ChartRoute extends CollectionRoute {
]);

// A count exposes the cardinality of the relation, which `/relationships/<name>/count` puts
// behind `browse`.
if (!aggregation.field) {
// behind `browse`. Added with the related-read checks, so it goes away with them.
if (!aggregation.field && !this.options.skipRelationReadPermissions) {
await this.services.authorization.assertCanBrowse(context, field.foreignCollection);
}

Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/routes/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export default class Capabilities extends BaseRoute {
// `x-forest-correlation-id` is emitted on every response regardless — the frontend must
// gate the History tab on this flag rather than inferring the feature from that header.
canUseAuditTrail: this.options.auditTrail !== null,
checksRelationReadPermissions: !this.options.skipRelationReadPermissions,
},
collections:
collections?.map(collection => {
Expand Down
12 changes: 11 additions & 1 deletion packages/agent/src/services/authorization/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ export type QueryComponent = 'filter' | 'sort' | 'search';
const ALL_QUERY_COMPONENTS: QueryComponent[] = ['filter', 'sort', 'search'];

export default class AuthorizationService {
constructor(private readonly forestAdminClient: ForestAdminClient) {}
constructor(
private readonly forestAdminClient: ForestAdminClient,
private readonly skipRelationReadPermissions = false,
) {}

public async assertCanBrowse(context: Context, collectionName: string) {
await this.assertCanOnCollection(CollectionActionEvent.Browse, context, collectionName);
Expand Down Expand Up @@ -72,6 +75,9 @@ export default class AuthorizationService {
requested: RequestedProjection,
): Promise<Projection> {
const { projection, namedByCaller } = requested;

if (this.skipRelationReadPermissions) return new Projection(...projection);

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]);
Expand Down Expand Up @@ -107,6 +113,8 @@ export default class AuthorizationService {
collection: Collection,
consumes: QueryComponent[] = ALL_QUERY_COMPONENTS,
): Promise<void> {
if (this.skipRelationReadPermissions) return;

const usages: FieldUsage[] = [];
const push = (action: string, path: string) =>
usages.push({
Expand Down Expand Up @@ -162,6 +170,8 @@ export default class AuthorizationService {
rootCollectionName: string,
usages: FieldUsage[],
): Promise<void> {
if (this.skipRelationReadPermissions) return;

const permissions = await this.getReadPermissions(
context,
rootCollectionName,
Expand Down
2 changes: 1 addition & 1 deletion packages/agent/src/services/authorization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ import AuthorizationService from './authorization';
export default function authorizationServiceFactory(
options: AgentOptionsWithDefaults,
): AuthorizationService {
return new AuthorizationService(options.forestAdminClient);
return new AuthorizationService(options.forestAdminClient, options.skipRelationReadPermissions);
}
9 changes: 9 additions & 0 deletions packages/agent/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ export type AgentOptions = {
*/
ignoreMissingSchemaElementErrors?: boolean;
useUnsafeActionEndpoint?: boolean;
/**
* Serve columns of collections the caller has no `read` permission on when they are reached
* through a relation path (`holder:nationalId`) in a projection, a filter, a sort, a search or a
* chart, and drop the `browse` a `Count` leaderboard requires on the collection it counts. No
* check on the collection being queried is affected: the route's own `browse`/`read`/`export`
* still runs.
* @default false
*/
skipRelationReadPermissions?: boolean;
/**
* Max number of records a "select all" approval-required action may target.
* Must not exceed the Forest server's own cap (500).
Expand Down
9 changes: 9 additions & 0 deletions packages/agent/src/utils/options-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default class OptionsValidator {
copyOptions.instantCacheRefresh = copyOptions.instantCacheRefresh ?? true;
copyOptions.workflowExecutorUrl = copyOptions.workflowExecutorUrl ?? null;
copyOptions.auditTrail = copyOptions.auditTrail ?? null;
copyOptions.skipRelationReadPermissions = copyOptions.skipRelationReadPermissions ?? false;
// Number.isFinite so NaN (e.g. Number() on an unset env var) also gets the default.
copyOptions.maxRecordsForApproval = Number.isFinite(copyOptions.maxRecordsForApproval)
? copyOptions.maxRecordsForApproval
Expand All @@ -61,6 +62,14 @@ export default class OptionsValidator {
);
}

if (copyOptions.skipRelationReadPermissions) {
copyOptions.logger(
'Warn',
'options.skipRelationReadPermissions=true: columns of collections the caller has no ' +
'read permission on are served when a relation path reaches them',
);
}

if (copyOptions.skipSchemaUpdate && copyOptions.experimental) {
copyOptions.logger(
'Warn',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export default Factory.define<AgentOptionsWithDefaults>(() => ({
},
ignoreMissingSchemaElementErrors: false,
useUnsafeActionEndpoint: false,
skipRelationReadPermissions: false,
maxRecordsForApproval: 500,
workflowExecutorUrl: null,
auditTrail: null,
Expand Down
28 changes: 28 additions & 0 deletions packages/agent/test/routes/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ describe('Capabilities', () => {
canUseProjectionViaHeaderOnList: true,
canUseMultipleFieldsProjectionOnRelation: true,
canUseAuditTrail: false,
checksRelationReadPermissions: true,
},
collections: [],
});
Expand All @@ -106,6 +107,7 @@ describe('Capabilities', () => {
canUseProjectionViaHeaderOnList: true,
canUseMultipleFieldsProjectionOnRelation: true,
canUseAuditTrail: false,
checksRelationReadPermissions: true,
},
collections: [],
});
Expand All @@ -129,12 +131,37 @@ describe('Capabilities', () => {
canUseProjectionViaHeaderOnList: true,
canUseMultipleFieldsProjectionOnRelation: true,
canUseAuditTrail: false,
checksRelationReadPermissions: true,
},
collections: [],
});
});
});

describe('when skipRelationReadPermissions is set', () => {
test('reports checksRelationReadPermissions: false so the front stops pruning related columns', async () => {
const unsafeOptions = factories.forestAdminHttpDriverOptions.build({
skipRelationReadPermissions: true,
});
const dataSource = factories.dataSource.buildWithCollection(
factories.collection.build({ name: 'books' }),
);
const unsafeRoute = new Capabilities(services, unsafeOptions, dataSource);
const context = createMockContext({
...defaultContext,
requestBody: { collectionNames: [] },
});

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await unsafeRoute.fetchCapabilities(context);

expect(context.response.body).toMatchObject({
agentCapabilities: { checksRelationReadPermissions: false },
});
});
});

describe('when auditTrail is configured', () => {
test('reports canUseAuditTrail: true so the front can gate the History tab on it', async () => {
const auditTrailOptions = factories.forestAdminHttpDriverOptions.build({
Expand Down Expand Up @@ -178,6 +205,7 @@ describe('Capabilities', () => {
canUseProjectionViaHeaderOnList: true,
canUseMultipleFieldsProjectionOnRelation: true,
canUseAuditTrail: false,
checksRelationReadPermissions: true,
},
collections: [
{
Expand Down
Loading
Loading