Merge pull request #34225 from backstage/fix/filter-predicate-mixed-operator-keys

fix(filter-predicates): reject operator keys mixed with other keys
This commit is contained in:
Fredrik Adelöw
2026-05-19 09:24:31 +02:00
committed by GitHub
5 changed files with 55 additions and 6 deletions
@@ -0,0 +1,5 @@
---
'@backstage/filter-predicates': patch
---
Filter predicates that mix operator keys (`$all`, `$any`, `$not`) with other keys are now rejected. Previously, a predicate like `{ kind: 'API', $not: { 'spec.type': 'dataset' } }` would silently drop the `kind` check. The correct form wraps conditions in `$all`.
@@ -258,6 +258,28 @@ describe('evaluate', () => {
});
});
it('throws when operator keys are mixed with other keys', () => {
const entity = entities[0];
expect(() =>
evaluateFilterPredicate(
{ kind: 'API', $not: { 'spec.type': 'dataset' } } as any,
entity,
),
).toThrow(/Operator \$not must be the only key/);
expect(() =>
evaluateFilterPredicate(
{ kind: 'API', $all: [{ 'spec.type': 'service' }] } as any,
entity,
),
).toThrow(/Operator \$all must be the only key/);
expect(() =>
evaluateFilterPredicate(
{ kind: 'API', $any: [{ 'spec.type': 'service' }] } as any,
entity,
),
).toThrow(/Operator \$any must be the only key/);
});
it('handles unknown filter predicate operators and matchers', () => {
const operator = { $unknown: 'foo' } as unknown as FilterPredicate;
const value = { kind: { $unknown: 'foo' } } as unknown as FilterPredicate;
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { InputError } from '@backstage/errors';
import { JsonValue } from '@backstage/types';
import { FilterPredicate, FilterPredicateValue } from './types';
import { getJsonValueAtPath } from './getJsonValueAtPath';
@@ -36,12 +37,27 @@ export function evaluateFilterPredicate(
}
if ('$all' in predicate) {
if (Object.keys(predicate).length !== 1) {
throw new InputError(
'Operator $all must be the only key in the predicate, wrap in $all to combine with other conditions',
);
}
return predicate.$all.every(f => evaluateFilterPredicate(f, value));
}
if ('$any' in predicate) {
if (Object.keys(predicate).length !== 1) {
throw new InputError(
'Operator $any must be the only key in the predicate, wrap in $all to combine with other conditions',
);
}
return predicate.$any.some(f => evaluateFilterPredicate(f, value));
}
if ('$not' in predicate) {
if (Object.keys(predicate).length !== 1) {
throw new InputError(
'Operator $not must be the only key in the predicate, wrap in $all to combine with other conditions',
);
}
return !evaluateFilterPredicate(predicate.$not, value);
}
@@ -108,6 +108,9 @@ describe('createZodV3FilterPredicateSchema', () => {
{ $not: { $all: [{ x: { $unknown: true } }] } },
{ $unknown: 'foo' },
{ 'metadata.tags': ['foo', 'bar'] },
{ kind: 'API', $not: { 'spec.type': 'dataset' } },
{ kind: 'API', $all: [{ 'spec.type': 'service' }] },
{ kind: 'API', $any: [{ 'spec.type': 'service' }] },
];
it.each(predicates)('should reject invalid predicate %j', predicate => {
@@ -207,6 +210,9 @@ describe('parseFilterPredicate', () => {
{ $unknown: 'foo' },
{ kind: { $hasPrefix: 1 } },
{ 'metadata.tags': ['foo', 'bar'] },
{ kind: 'API', $not: { 'spec.type': 'dataset' } },
{ kind: 'API', $all: [{ 'spec.type': 'service' }] },
{ kind: 'API', $any: [{ 'spec.type': 'service' }] },
];
it.each(predicates)(
@@ -53,9 +53,9 @@ export function createZodV3FilterPredicateSchema(
z.union([
expressionSchema,
primitiveSchema,
z.object({ $all: z.array(predicateSchema) }),
z.object({ $any: z.array(predicateSchema) }),
z.object({ $not: predicateSchema }),
z.object({ $all: z.array(predicateSchema) }).strict(),
z.object({ $any: z.array(predicateSchema) }).strict(),
z.object({ $not: predicateSchema }).strict(),
]),
) as zodV3.ZodType<FilterPredicate>;
@@ -104,9 +104,9 @@ export function createZodV4FilterPredicateSchema(): zodV4.ZodType<
z.union([
expressionSchema,
primitiveSchema,
z.object({ $all: z.array(predicateSchema) }),
z.object({ $any: z.array(predicateSchema) }),
z.object({ $not: predicateSchema }),
z.object({ $all: z.array(predicateSchema) }).strict(),
z.object({ $any: z.array(predicateSchema) }).strict(),
z.object({ $not: predicateSchema }).strict(),
]),
) as zodV4.ZodType<FilterPredicate, FilterPredicate>;