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';
}
Comment on lines +195 to +212

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 variable typed string 🐞 Bug ≡ Correctness

handleBinaryOperator declares single-value enum filter variables as type "String" while declaring
the same enum field as "[ProcessInstanceState!]" for array values, so one of those queries must be
invalid against the fixed server schema type. This can cause runtime GraphQL validation errors when
applying filters on the enum field.
Agent Prompt
### Issue description
`handleBinaryOperator` assigns `clauseVariableType = 'String'` for all single-value filters, even when `isEnumFilter(...)` is true. In the array branch it uses `[ProcessInstanceState!]`, meaning the same enum field will produce different GraphQL variable types depending on value cardinality, and `queryBuilder` will emit those types directly into the operation signature.

### Issue Context
GraphQL input field types are fixed in the schema; they cannot be both `String` and `ProcessInstanceState` depending on operator/value shape. With the current code, at least one of the enum filter modes will fail schema validation.

### Fix Focus Areas
- workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilder.ts[183-212]
- workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/queryBuilder.ts[20-36]

### What to change
- When `isEnumFilter(binaryFilter.field, type)` is true, set the single-value `paramType` to the correct enum type (e.g., `ProcessInstanceState` or `ProcessInstanceState!`) instead of `String`.
- Keep array and single-value enum typing consistent (e.g., `ProcessInstanceState` vs `[ProcessInstanceState!]`).
- If you don’t want to hardcode enum type names, consider deriving the correct variable type from schema/introspection and mapping it to the appropriate GraphQL type string.

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


const clauseVariableName = `clauseVariable${nonSecureRandomAlphaNumeric()}`;
const clause = `${binaryFilter.field}: {${getGraphQLOperator(binaryFilter.operator)}: $${clauseVariableName}}`;
const filterClauseVariable: FilterClauseVariable = {
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;
}
Comment on lines +236 to 239

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

2. Empty filterclause breaks queries 🐞 Bug ☼ Reliability

buildFilterCondition/handleLogicalFilter return {} cast as FilterClause, leaving clause and
clauseVariable undefined; downstream code treats the object as truthy and interpolates undefined
into the where clause. This can produce malformed GraphQL queries (e.g., {undefined} conditions)
and/or crash when iterating clauseVariable.
Agent Prompt
### Issue description
`buildFilterCondition` (and `handleLogicalFilter` when `operator` is missing) returns `{}` cast to `FilterClause`. This violates the `FilterClause` contract and leads to `undefined` being interpolated into `whereClause` and risks runtime errors when code assumes `clauseVariable` is an array.

### Issue Context
`DataIndexService.fetchInstances`/`fetchWorkflowInfos` treat any object returned by `buildFilterCondition` as truthy and append `{${filterCondition?.clause}}` into conditions. If `clause` is missing, the query string becomes invalid.

### Fix Focus Areas
- workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilder.ts[76-92]
- workspaces/orchestrator/plugins/orchestrator-backend/src/helpers/filterBuilder.ts[231-239]
- workspaces/orchestrator/plugins/orchestrator-backend/src/service/DataIndexService.ts[235-247]
- workspaces/orchestrator/plugins/orchestrator-backend/src/service/DataIndexService.ts[312-314]

### What to change
- Stop returning `{}` for “no filter”. Prefer one of:
  - Change the return type to `FilterClause | undefined` and return `undefined` for no-op cases, updating callers to check `filterCondition?.clause`.
  - Or always return a valid empty clause object: `{ clause: '', clauseVariable: [] }`.
- For `handleLogicalFilter`, since `LogicalFilter.operator` is required by the API type, consider throwing if it’s missing rather than returning an empty object.
- Ensure callers only append filter conditions when `filterCondition.clause` is non-empty.

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


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