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
6 changes: 6 additions & 0 deletions workspaces/orchestrator/.changeset/five-meals-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@red-hat-developer-hub/backstage-plugin-orchestrator-backend': patch
---

- Update dependecy @urql/core to fix CVE-2026-3118
- Reworks the filter and query builder code to use query variables
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"@backstage/plugin-scaffolder-node": "^0.12.1",
"@red-hat-developer-hub/backstage-plugin-orchestrator-common": "workspace:^",
"@red-hat-developer-hub/backstage-plugin-orchestrator-node": "workspace:^",
"@urql/core": "^4.1.4",
"@urql/core": "^6.0.1",
"ajv-formats": "^2.1.1",
"cloudevents": "^8.0.0",
"express": "^4.21.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import {
TypeName,
} from '@red-hat-developer-hub/backstage-plugin-orchestrator-common';

import { randomBytes } from 'node:crypto';

import { FilterClause, FilterClauseVariable } from '../types/filterClause';

type ProcessType = 'ProcessDefinition' | 'ProcessInstance';

const supportedOperators = [
Expand Down Expand Up @@ -73,44 +77,88 @@ function handleLogicalFilter(
introspection: IntrospectionField[],
type: ProcessType,
filter: LogicalFilter,
): string {
if (!filter.operator) return '';
): FilterClause {
if (!filter.operator) return {} as FilterClause;

const subClauses = filter.filters.map(f =>
buildFilterCondition(introspection, type, f),
);

return `${filter.operator.toLowerCase()}: {${subClauses.join(', ')}}`;
const filterClause: FilterClause = {
clause: `${filter.operator.toLowerCase()}: {${subClauses.map(cl => cl.clause).join(', ')}}`,
clauseVariable: subClauses.flatMap(cl => cl.clauseVariable),
};
return filterClause;
}

function handleNestedFilter(
introspection: IntrospectionField[],
type: ProcessType,
filter: NestedFilter,
): string {
): FilterClause {
const subClauses = buildFilterCondition(
introspection,
type,
filter.nested,
true,
);

return `${filter.field}: {${subClauses}}`;
const filterClause: FilterClause = {
clauseVariable: subClauses.clauseVariable,
clause: `${filter.field}: {${subClauses.clause}}`,
};

return filterClause;
}

function handleBetweenOperator(filter: FieldFilter): string {
function handleBetweenOperator(filter: FieldFilter): FilterClause {
if (!Array.isArray(filter.value) || filter.value.length !== 2) {
throw new Error('Between operator requires an array of two elements');
}
return `${filter.field}: {${getGraphQLOperator(
const filterClauseVariableArray: FilterClauseVariable[] = [];
const clauseVariableName1 = `clauseVariable${nonSecureRandomAlphaNumeric()}`;
const filterClauseVariable1: FilterClauseVariable = {
clauseVariableName: clauseVariableName1,
formattedValue: filter.value[0],
clauseVariableType: 'String',
};

const clauseVariableName2 = `clauseVariable${nonSecureRandomAlphaNumeric()}`;
const filterClauseVariable2: FilterClauseVariable = {
clauseVariableName: clauseVariableName2,
formattedValue: filter.value[1],
clauseVariableType: 'String',
};

const clause = `${filter.field}: {${getGraphQLOperator(
FieldFilterOperatorEnum.Between,
)}: {from: "${filter.value[0]}", to: "${filter.value[1]}"}}`;
)}: {from: $${clauseVariableName1}, to: $${clauseVariableName2}}}`;
filterClauseVariableArray.push(filterClauseVariable1, filterClauseVariable2);
const filterClause: FilterClause = {
clause: clause,
clauseVariable: filterClauseVariableArray,
};

return filterClause;
}

function handleIsNullOperator(filter: FieldFilter): string {
return `${filter.field}: {${getGraphQLOperator(
FieldFilterOperatorEnum.IsNull,
)}: ${convertToBoolean(filter.value)}}`;
function handleIsNullOperator(filter: FieldFilter): FilterClause {
const clauseVariableName = `clauseVariable${nonSecureRandomAlphaNumeric()}`;
const clause = `${filter.field}: {${getGraphQLOperator(FieldFilterOperatorEnum.IsNull)}: $${clauseVariableName}}`;

const filterClauseVariable: FilterClauseVariable = {
clauseVariableName: clauseVariableName,
formattedValue: convertToBoolean(filter.value),
clauseVariableType: 'Boolean',
};
const filterClauseVariableArray: FilterClauseVariable[] = [];
filterClauseVariableArray.push(filterClauseVariable);
const clauseObject: FilterClause = {
clauseVariable: filterClauseVariableArray,
clause,
};

return clauseObject;
}

function isEnumFilter(
Expand All @@ -136,32 +184,58 @@ function handleBinaryOperator(
binaryFilter: FieldFilter,
fieldDef: IntrospectionField | undefined,
type: 'ProcessDefinition' | 'ProcessInstance',
): string {
): FilterClause {
if (isEnumFilter(binaryFilter.field, type)) {
if (!isValidEnumOperator(binaryFilter.operator)) {
throw new Error(
`Invalid operator ${binaryFilter.operator} for enum field ${binaryFilter.field} filter`,
);
}
}
const formattedValue = Array.isArray(binaryFilter.value)
? `[${binaryFilter.value
.map(v => formatValue(binaryFilter.field, v, fieldDef, type))
.join(', ')}]`
: formatValue(binaryFilter.field, binaryFilter.value, fieldDef, type);
return `${binaryFilter.field}: {${getGraphQLOperator(
binaryFilter.operator,
)}: ${formattedValue}}`;
let formattedValue: any;
let paramType: string;
if (Array.isArray(binaryFilter.value)) {
formattedValue = binaryFilter.value.map(v =>
formatValue(binaryFilter.field, v, fieldDef, type),
);
paramType = isEnumFilter(binaryFilter.field, type)
? '[ProcessInstanceState!]'
: '[String!]';
} else {
formattedValue = formatValue(
binaryFilter.field,
binaryFilter.value,
fieldDef,
type,
);
paramType = 'String';
}

const clauseVariableName = `clauseVariable${nonSecureRandomAlphaNumeric()}`;
const clause = `${binaryFilter.field}: {${getGraphQLOperator(binaryFilter.operator)}: $${clauseVariableName}}`;
const filterClauseVariable: FilterClauseVariable = {
Comment on lines +195 to +216
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Enum typed as string 🐞 Bug ≡ Correctness

handleBinaryOperator() declares scalar (non-array) filter variables as type String even when
isEnumFilter() marks the field (e.g. state) as an enum, which can make the generated GraphQL
query invalid due to variable type mismatch. As a result, filtering ProcessInstance.state with
EQ can fail at query validation/execution time while IN works (since it uses
[ProcessInstanceState!]).
Agent Prompt
### Issue description
`handleBinaryOperator()` generates GraphQL variable declarations for filter values. For enum fields (currently `ProcessInstance.state`), the array path correctly uses an enum type (`[ProcessInstanceState!]`), but the scalar path always uses `String`, which can break GraphQL validation/execution.

### Issue Context
- `isEnumFilter()` marks `state` as an enum field.
- Array enum filters use `[ProcessInstanceState!]`.
- Scalar enum filters currently use `String`.

### Fix Focus Areas
- workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilder.ts[164-229]

### What to change
- In the scalar (`else`) branch inside `handleBinaryOperator()`, set `paramType` based on `isEnumFilter(binaryFilter.field, type)` (e.g., `ProcessInstanceState` for `state`) instead of hard-coding `String`.
- (Optional hardening) Centralize enum-type mapping so future enum fields don’t require scattered hard-coded types.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

clauseVariableName: clauseVariableName,
formattedValue: formattedValue,
clauseVariableType: paramType,
};
const filterClauseVariableArray: FilterClauseVariable[] = [];
filterClauseVariableArray.push(filterClauseVariable);
const clauseObject: FilterClause = {
clauseVariable: filterClauseVariableArray,
clause,
};

return clauseObject;
}

export function buildFilterCondition(
introspection: IntrospectionField[],
type: ProcessType,
filters?: Filter,
isNested?: boolean,
): string {
): FilterClause {
if (!filters) {
return '';
return {} as FilterClause;
}

if (isNestedFilter(filters)) {
Expand Down Expand Up @@ -255,7 +329,7 @@ function formatValue(
type: ProcessType,
): string {
if (!fieldDef) {
return `"${fieldValue}"`;
return `${fieldValue}`;
}

if (!isFieldFilterSupported) {
Expand All @@ -270,7 +344,7 @@ function formatValue(
fieldDef.type.name === TypeName.Id ||
fieldDef.type.name === TypeName.Date
) {
return `"${fieldValue}"`;
return `${fieldValue}`;
}
throw new Error(
`Failed to format value for ${fieldName} ${fieldValue} with type ${fieldDef.type.name}`,
Expand Down Expand Up @@ -301,3 +375,9 @@ function getGraphQLOperator(operator: FieldFilterOperatorEnum): string {
throw new Error(`Operation "${operator}" not supported`);
}
}

// Function for getting 4 random digits to append to the clause variable name.
// Not used for any secrets or anything
function nonSecureRandomAlphaNumeric() {
return randomBytes(8).toString('hex');
}
Loading
Loading