fix(server): stop FIND_RECORDS from silently ignoring its filter (#23640)

## Problem

A FIND_RECORDS workflow step with a filter configured could silently
return every record (and thus the first row of the table) instead of
applying the filter. Ways to hit it:

- `recordFilters` set but `recordFilterGroups` omitted (e.g. an
API/agent caller, or any non-UI config).
- A grouped filter (carrying `recordFilterGroupId`) whose
`recordFilterGroups` is missing.
- A filter referencing an unknown `fieldMetadataId` or an unresolvable
relation (`turnRecordFilterIntoRecordGqlOperationFilter` returns
`undefined`, silently dropped).
- `gqlOperationFilter` set, which passed validation but was never read.

In every case the computed filter collapses to `{}`,
`FindRecordsService` returns all records ordered by `id ASC`, and
`records[0]` is the first row. This is fail-open: the step reports
success and returns wrong records rather than erroring.

## Fix

- Compute the filter whenever `recordFilters` is non-empty, defaulting
`recordFilterGroups` to `[]`. `computeRecordGqlOperationFilter` handles
ungrouped filters independently of groups.
- **Fail closed**: if `recordFilters` is non-empty but the computed
`gqlOperationFilter` is empty, throw `INVALID_STEP_INPUT` instead of
running an unfiltered query. This covers grouped-without-groups and
unknown-field/unresolvable-relation cases raised in review. An absent or
empty `recordFilters` still legitimately means "find all".
- Remove the unused `gqlOperationFilter` field from the find-records
input type and settings schema so it is no longer advertised as a filter
option (it has been dead since #16147, when the action moved to
computing the filter at runtime from `recordFilters`).
This commit is contained in:
Thomas Trompette
2026-08-03 10:24:44 +02:00
committed by GitHub
parent 9df893ead1
commit ad8830ecbf
3 changed files with 25 additions and 20 deletions
@@ -1,7 +1,4 @@
import {
type ObjectRecordFilter,
type ObjectRecordOrderBy,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type ObjectRecordProperties } from './object-record-properties.type';
@@ -30,7 +27,6 @@ export type FindRecordsInput = {
recordFilterGroups?: any;
// oxlint-disable-next-line typescript/no-explicit-any
recordFilters?: any;
gqlOperationFilter?: Partial<ObjectRecordFilter>[];
};
orderBy?: {
// oxlint-disable-next-line typescript/no-explicit-any
@@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common';
import {
computeRecordGqlOperationFilter,
isDefined,
isEmptyObject,
isNonEmptyArray,
isRecordFilterValueValid,
resolveInput,
} from 'twenty-shared/utils';
@@ -75,20 +77,28 @@ export class FindRecordsWorkflowAction implements WorkflowAction {
}
}
const gqlOperationFilter =
workflowActionInput.filter?.recordFilters &&
workflowActionInput.filter?.recordFilterGroups
? computeRecordGqlOperationFilter({
fieldMetadataItems: Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
).filter(isDefined),
recordFilters: workflowActionInput.filter.recordFilters,
recordFilterGroups: workflowActionInput.filter.recordFilterGroups,
filterValueDependencies: {
timeZone: 'UTC',
},
})
: {};
const recordFilters = workflowActionInput.filter?.recordFilters;
const gqlOperationFilter = isDefined(recordFilters)
? computeRecordGqlOperationFilter({
fieldMetadataItems: Object.values(
flatFieldMetadataMaps.byUniversalIdentifier,
).filter(isDefined),
recordFilters,
recordFilterGroups:
workflowActionInput.filter?.recordFilterGroups ?? [],
filterValueDependencies: {
timeZone: 'UTC',
},
})
: {};
if (isNonEmptyArray(recordFilters) && isEmptyObject(gqlOperationFilter)) {
throw new WorkflowStepExecutorException(
'Filter could not be resolved to a valid query. Check that filtered fields exist and that grouped filters include their recordFilterGroups.',
WorkflowStepExecutorExceptionCode.INVALID_STEP_INPUT,
);
}
const toolOutput = await this.findRecordsService.execute({
objectName: workflowActionInput.objectName,
@@ -11,7 +11,6 @@ export const workflowFindRecordsActionSettingsSchema =
.object({
recordFilterGroups: z.array(z.any()).optional(),
recordFilters: z.array(z.any()).optional(),
gqlOperationFilter: z.any().optional().nullable(),
})
.optional(),
orderBy: z