diff --git a/workspaces/orchestrator/.changeset/quiet-otters-behave.md b/workspaces/orchestrator/.changeset/quiet-otters-behave.md new file mode 100644 index 00000000000..e35426f7921 --- /dev/null +++ b/workspaces/orchestrator/.changeset/quiet-otters-behave.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator-backend': patch +--- + +Bound the page size of the `list-workflows` and `list-instances` MCP actions (default 50, max 100, with `limit`/`offset` inputs) so they no longer fetch every visible workflow/instance in a single unbounded call. Also cache compiled Ajv validators in the `execute-workflow` action instead of recompiling the workflow's input schema on every invocation. diff --git a/workspaces/orchestrator/.changeset/tame-plums-relax.md b/workspaces/orchestrator/.changeset/tame-plums-relax.md new file mode 100644 index 00000000000..22a32da56c2 --- /dev/null +++ b/workspaces/orchestrator/.changeset/tame-plums-relax.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-orchestrator-backend': minor +--- + +Add 5 Orchestrator MCP actions (`list-workflows`, `get-workflow-schema`, `execute-workflow`, `list-instances`, `get-instance`) so LLM/CLI clients can discover and run Orchestrator workflows through the Model Context Protocol. Each action enforces the existing Orchestrator RBAC permissions (`orchestrator.workflow`, `orchestrator.workflow.use`, `orchestrator.instanceAdminView`) with full fidelity, including conditional policies and instance ownership checks. diff --git a/workspaces/orchestrator/app-config.yaml b/workspaces/orchestrator/app-config.yaml index b577ee74101..ded3d513350 100644 --- a/workspaces/orchestrator/app-config.yaml +++ b/workspaces/orchestrator/app-config.yaml @@ -26,6 +26,20 @@ backend: # echo mycurlpasswd | base64 token: bXljdXJscGFzc3dkCg== # NOSONAR subject: my-external-feed + # Uncomment to let MCP clients (e.g. Cursor, Claude Code) authenticate + # against the Orchestrator MCP Actions below - see + # plugins/orchestrator-backend/README.md#mcp-actions + # - type: static + # options: + # token: ${MCP_TOKEN} + # subject: mcp-clients + # Uncomment this when Orchestrator MCP Actions are needed (RHIDP-14041) - + # without this, actions registered via the Actions Registry are not + # exposed through @backstage/plugin-mcp-actions-backend, even though the + # plugin is wired in packages/backend/src/index.ts. + # actions: + # pluginSources: + # - 'orchestrator' listen: port: 7007 # Uncomment the following host directive to bind to specific interfaces diff --git a/workspaces/orchestrator/packages/backend/package.json b/workspaces/orchestrator/packages/backend/package.json index d138d662a04..bf93f31834f 100644 --- a/workspaces/orchestrator/packages/backend/package.json +++ b/workspaces/orchestrator/packages/backend/package.json @@ -34,6 +34,7 @@ "@backstage/plugin-catalog-backend-module-gitlab": "^0.8.4", "@backstage/plugin-catalog-backend-module-logs": "^0.1.23", "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "^0.2.21", + "@backstage/plugin-mcp-actions-backend": "^0.1.14", "@backstage/plugin-notifications-backend": "^0.6.6", "@backstage/plugin-permission-backend": "^0.7.13", "@backstage/plugin-permission-backend-module-allow-all-policy": "^0.2.20", diff --git a/workspaces/orchestrator/packages/backend/src/index.ts b/workspaces/orchestrator/packages/backend/src/index.ts index f0d76f169c0..4b02ca6a424 100644 --- a/workspaces/orchestrator/packages/backend/src/index.ts +++ b/workspaces/orchestrator/packages/backend/src/index.ts @@ -55,6 +55,10 @@ backend.add(import('@backstage/plugin-search-backend-module-pg')); backend.add(import('@backstage/plugin-search-backend-module-catalog')); backend.add(import('@backstage/plugin-search-backend-module-techdocs')); +// MCP actions (required for the Orchestrator MCP actions to be reachable by +// an LLM/CLI client — see RHIDP-14041) +backend.add(import('@backstage/plugin-mcp-actions-backend')); + // orchestrator backend.add( import('@red-hat-developer-hub/backstage-plugin-orchestrator-backend'), diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/README.md b/workspaces/orchestrator/plugins/orchestrator-backend/README.md index ccdd71d6d33..cbf33e5f721 100644 --- a/workspaces/orchestrator/plugins/orchestrator-backend/README.md +++ b/workspaces/orchestrator/plugins/orchestrator-backend/README.md @@ -3,3 +3,72 @@ Welcome to the backend package for the Orchestrator plugin! For more information about the Orchestrator plugin, see the [Orchestrator Plugin documentation](https://github.com/redhat-developer/rhdh-plugins/tree/main/workspaces/orchestrator/plugins/orchestrator) on GitHub. + +## MCP Actions + +The Orchestrator backend plugin registers MCP (Model Context Protocol) actions that allow AI agents and MCP clients (e.g. Cursor, Claude Code) to discover Orchestrator workflows and instances, and to run workflows, programmatically. + +### Available actions + +| Action | Description | +| --------------------- | ------------------------------------------------------------------------------------------------------------ | +| `list-workflows` | Lists the workflow definitions visible to the caller, optionally filtered by name and/or last-run status. | +| `get-workflow-schema` | Returns a workflow's input JSON schema. Call this before `execute-workflow` to learn what inputs it expects. | +| `execute-workflow` | Starts a new run of a workflow with the given inputs, validated against the workflow's own schema first. | +| `list-instances` | Lists workflow runs (instances) visible to the caller, optionally filtered by status. | +| `get-instance` | Fetches a single workflow instance's status, timestamps, and output data. | + +These actions enforce the same Backstage permissions as the Orchestrator REST API (`orchestrator.workflow`, `orchestrator.workflow.use`, `orchestrator.instanceAdminView`) — no separate permission model is introduced for MCP. + +### Enabling MCP Actions + +To enable MCP actions, install the `@backstage/plugin-mcp-actions-backend` package and configure authentication: + +1. Install the MCP actions backend plugin: + +```bash +# From your root directory +yarn --cwd packages/backend add @backstage/plugin-mcp-actions-backend +``` + +2. Add the plugin to your backend in `packages/backend/src/index.ts`: + +```ts +backend.add(import('@backstage/plugin-mcp-actions-backend')); +``` + +3. Add the orchestrator plugin as an action source and configure a static token for MCP client authentication in your `app-config.yaml`: + +```yaml +backend: + actions: + pluginSources: + - 'orchestrator' + auth: + externalAccess: + - type: static + options: + token: ${MCP_TOKEN} + subject: mcp-clients +``` + +4. Set the `MCP_TOKEN` environment variable (8 characters or longer) before starting the backend. + +### Interacting with MCP Actions + +See the [Backstage MCP Actions Backend documentation](https://github.com/backstage/backstage/tree/master/plugins/mcp-actions-backend#configuring-mcp-clients) for more information on configuring MCP clients. + +Sample `mcp.json` for Cursor: + +```json +{ + "mcpServers": { + "backstage-actions": { + "url": "http://localhost:7007/api/mcp-actions/v1", + "headers": { + "Authorization": "Bearer ${MCP_TOKEN}" + } + } + } +} +``` diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/package.json b/workspaces/orchestrator/plugins/orchestrator-backend/package.json index c2fdd8aa2fc..bd8784bba00 100644 --- a/workspaces/orchestrator/plugins/orchestrator-backend/package.json +++ b/workspaces/orchestrator/plugins/orchestrator-backend/package.json @@ -59,6 +59,7 @@ "lint:check": "backstage-cli package lint", "lint:fix": "backstage-cli package lint --fix", "test": "backstage-cli package test --passWithNoTests --coverage", + "test:integration": "backstage-cli package test src/mcp-tools.integration.test.ts --watch=false", "clean": "backstage-cli package clean", "prepack": "backstage-cli package prepack", "postpack": "backstage-cli package postpack", @@ -79,6 +80,7 @@ "@red-hat-developer-hub/backstage-plugin-orchestrator-common": "workspace:^", "@red-hat-developer-hub/backstage-plugin-orchestrator-node": "workspace:^", "@urql/core": "^6.0.1", + "ajv": "^8.17.1", "ajv-formats": "^2.1.1", "cloudevents": "^10.0.0", "express": "^4.21.2", @@ -97,8 +99,10 @@ "@backstage-community/plugin-rbac-common": "^1.29.0", "@backstage/backend-test-utils": "^1.11.4", "@backstage/cli": "^0.36.3", + "@backstage/plugin-mcp-actions-backend": "^0.1.14", "@janus-idp/backstage-plugin-audit-log-node": "^1.7.1", "@janus-idp/cli": "3.7.0", + "@modelcontextprotocol/sdk": "^1.25.2", "@types/express": "4.17.25", "@types/fs-extra": "11.0.4", "@types/json-schema": "7.0.15", diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/__testUtils__/mcpTestUtils.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/__testUtils__/mcpTestUtils.ts new file mode 100644 index 00000000000..d3abeb3d6d4 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/__testUtils__/mcpTestUtils.ts @@ -0,0 +1,237 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + mockCredentials, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; +import mcpPlugin from '@backstage/plugin-mcp-actions-backend'; +import { + AuthorizeResult, + type PolicyDecision, +} from '@backstage/plugin-permission-common'; + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +import type { Server } from 'node:http'; + +// Must be imported after the `jest.mock` calls above so that `orchestratorPlugin`'s +// transitive imports resolve to the mocked service classes. +// eslint-disable-next-line import/first +import { orchestratorPlugin } from '../plugin'; + +// Orchestrator has no official test double for SonataFlowService / +// DataIndexService / WorkflowCacheService / OrchestratorService (unlike e.g. +// Scorecard's `catalogServiceMock`). The only established convention for +// stubbing them in this package is `service/router.test.ts`'s same-package, +// relative-path `jest.mock(...)` calls, reused here verbatim so the real +// `orchestratorPlugin` boots through `startTestBackend` without touching a +// real SonataFlow/DataIndex service. +jest.mock('../service/DataIndexService', () => ({ + DataIndexService: jest.fn().mockImplementation(() => ({})), +})); + +jest.mock('../service/SonataFlowService', () => ({ + SonataFlowService: jest.fn().mockImplementation(() => ({})), +})); + +jest.mock('../service/WorkflowCacheService', () => ({ + WorkflowCacheService: jest.fn().mockImplementation(() => ({ + schedule: jest.fn(), + })), +})); + +export const mockOrchestratorService = { + fetchInstance: jest.fn(), + fetchWorkflowOverviews: jest.fn(), + fetchWorkflowInfo: jest.fn(), + fetchWorkflowDefinition: jest.fn(), + fetchWorkflowInfoOnService: jest.fn(), + executeWorkflow: jest.fn(), + fetchInstances: jest.fn(), + getWorkflowIds: jest.fn(), +}; + +jest.mock('../service/OrchestratorService', () => ({ + OrchestratorService: jest + .fn() + .mockImplementation(() => mockOrchestratorService), +})); + +export type BackendPermissionMode = 'allow-all' | 'deny-all'; + +export type StartMcpBackendOptions = { + permissionMode?: BackendPermissionMode; +}; + +function getServerPort(server: Server): number { + const address = server.address(); + if (typeof address !== 'object' || !address || !('port' in address)) { + throw new Error('Test backend server address is unavailable'); + } + return address.port; +} + +function createPermissionsFactory(mode: BackendPermissionMode) { + const decision: PolicyDecision = + mode === 'deny-all' + ? { result: AuthorizeResult.DENY } + : { result: AuthorizeResult.ALLOW }; + + return mockServices.permissions.mock({ + authorize: async () => [decision], + authorizeConditional: async () => [decision], + }).factory; +} + +export async function startMcpBackend({ + permissionMode = 'allow-all', +}: StartMcpBackendOptions = {}) { + return startTestBackend({ + features: [ + orchestratorPlugin, + mcpPlugin, + mockServices.rootConfig.factory({ + data: { + backend: { + baseUrl: 'http://localhost:7007', + actions: { pluginSources: ['orchestrator'] }, + }, + orchestrator: { + dataIndexService: { url: 'http://localhost:8080' }, + }, + }, + }), + mockServices.auth.factory(), + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user('user:default/test'), + }), + createPermissionsFactory(permissionMode), + mockServices.database.factory(), + mockServices.cache.factory(), + ], + }); +} + +const MCP_TRANSPORT_RECONNECTION_OPTIONS = { + initialReconnectionDelay: 0, + maxReconnectionDelay: 0, + reconnectionDelayGrowFactor: 1, + maxRetries: 0, +} as const; + +function createMcpTransport(server: Server): StreamableHTTPClientTransport { + return new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${getServerPort(server)}/api/mcp-actions/v1`), + { + reconnectionOptions: { ...MCP_TRANSPORT_RECONNECTION_OPTIONS }, + }, + ); +} + +async function closeMcpConnection( + client: Client, + transport: StreamableHTTPClientTransport, +): Promise { + try { + await transport.terminateSession(); + } catch { + // MCP servers may return 405 when session termination is unsupported. + } + + await client.close(); +} + +export async function withMcpClient( + server: Server, + run: (client: Client) => Promise, +): Promise { + const client = new Client({ + name: 'orchestrator-mcp-integration-test', + version: '1.0.0', + }); + + const transport = createMcpTransport(server); + + try { + await client.connect(transport); + return await run(client); + } finally { + await closeMcpConnection(client, transport); + } +} + +export type CallToolResult = { + structuredContent?: unknown; + content?: Array<{ type: string; text?: string }>; + isError?: boolean; +}; + +function stripMarkdownJsonFence(text: string): string { + let jsonText = text.trim(); + + if (jsonText.toLowerCase().startsWith('```json')) { + jsonText = jsonText.slice('```json'.length); + } + + if (jsonText.endsWith('```')) { + jsonText = jsonText.slice(0, -3); + } + + return jsonText.trim(); +} + +export function parseCallToolOutput(result: unknown): unknown { + const callResult = result as CallToolResult; + + if ( + 'structuredContent' in callResult && + callResult.structuredContent !== undefined + ) { + return callResult.structuredContent; + } + + if ('content' in callResult && Array.isArray(callResult.content)) { + for (const item of callResult.content) { + if ( + item.type === 'text' && + 'text' in item && + typeof item.text === 'string' + ) { + return JSON.parse(stripMarkdownJsonFence(item.text)); + } + } + } + + throw new Error('Call tool result did not include parseable output'); +} + +export function parseCallToolError(result: unknown): string { + const callResult = result as CallToolResult; + const messages: string[] = []; + + if ('content' in callResult && Array.isArray(callResult.content)) { + for (const item of callResult.content) { + if (item.type === 'text' && typeof item.text === 'string') { + messages.push(item.text); + } + } + } + + return messages.join('\n'); +} diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/__fixtures__/testConditionTransformer.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/__fixtures__/testConditionTransformer.ts new file mode 100644 index 00000000000..a2d7a188606 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/__fixtures__/testConditionTransformer.ts @@ -0,0 +1,38 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createConditionTransformer } from '@backstage/plugin-permission-node'; + +import { + OrchestratorFilters, + orchestratorPermissionRules, +} from '../../service/permission-rules'; + +/** + * Same `permissionsRegistry.getPermissionRuleset(orchestratorWorkflowResourceRef)` + * shape used in production (see `plugin.ts`/`router.ts`), for use in action + * unit tests that need a real `ConditionTransformer`. + */ +export const testConditionTransformer = + createConditionTransformer({ + getRuleByName: (name: string) => { + const rule = orchestratorPermissionRules.find(r => r.name === name); + if (!rule) { + throw new Error(`Unknown rule: ${name}`); + } + return rule; + }, + }); diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/executeWorkflow.test.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/executeWorkflow.test.ts new file mode 100644 index 00000000000..1b8ef70fe4c --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/executeWorkflow.test.ts @@ -0,0 +1,402 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockServices } from '@backstage/backend-test-utils'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { + InputError, + NotAllowedError, + NotFoundError, + ServiceUnavailableError, +} from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +import Ajv from 'ajv'; + +import { OrchestratorService } from '../service/OrchestratorService'; +import { testConditionTransformer as conditionTransformer } from './__fixtures__/testConditionTransformer'; +import { createExecuteWorkflowAction } from './executeWorkflow'; + +// RHIDP-14046: execute-workflow must validate inputs against the workflow's +// own schema before executing, enforce the "use" permission, and return the +// new instance's ID and initial status. +describe('createExecuteWorkflowAction', () => { + const logger = mockServices.logger.mock(); + const mockUserInfo = mockServices.userInfo.mock(); + + const mockOrchestratorService = { + fetchWorkflowInfo: jest.fn(), + fetchWorkflowDefinition: jest.fn(), + fetchWorkflowInfoOnService: jest.fn(), + executeWorkflow: jest.fn(), + fetchInstance: jest.fn(), + } as unknown as OrchestratorService; + + beforeEach(() => { + jest.resetAllMocks(); + mockUserInfo.getUserInfo.mockResolvedValue({ + userEntityRef: 'user:default/jdoe', + ownershipEntityRefs: [], + }); + }); + + function allowAccess( + mockPermissions: ReturnType, + ) { + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + } + + function setUpWorkflow({ + dataInputSchema, + inputSchema, + }: { + dataInputSchema?: string; + inputSchema?: object; + } = {}) { + (mockOrchestratorService.fetchWorkflowInfo as jest.Mock).mockResolvedValue({ + id: 'workflow1', + serviceUrl: 'http://svc', + }); + ( + mockOrchestratorService.fetchWorkflowDefinition as jest.Mock + ).mockResolvedValue({ dataInputSchema }); + ( + mockOrchestratorService.fetchWorkflowInfoOnService as jest.Mock + ).mockResolvedValue({ id: 'workflow1', inputSchema }); + } + + it('executes the workflow and returns the instance id and status when there is no input schema', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + setUpWorkflow(); + (mockOrchestratorService.executeWorkflow as jest.Mock).mockResolvedValue({ + id: 'instance-1', + }); + (mockOrchestratorService.fetchInstance as jest.Mock).mockResolvedValue({ + id: 'instance-1', + processId: 'workflow1', + state: 'ACTIVE', + nodes: [], + }); + + createExecuteWorkflowAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:execute-workflow', + input: { workflowId: 'workflow1', inputs: { name: 'test' } }, + }); + + expect(result.output).toMatchObject({ + instanceId: 'instance-1', + status: 'ACTIVE', + }); + expect(mockOrchestratorService.executeWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + definitionId: 'workflow1', + serviceUrl: 'http://svc', + inputData: expect.objectContaining({ + workflowdata: { name: 'test' }, + initiatorEntity: 'user:default/jdoe', + }), + }), + ); + }); + + it('validates inputs against the schema and executes when valid', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + setUpWorkflow({ + dataInputSchema: 'schema.json', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }); + (mockOrchestratorService.executeWorkflow as jest.Mock).mockResolvedValue({ + id: 'instance-1', + }); + (mockOrchestratorService.fetchInstance as jest.Mock).mockResolvedValue({ + id: 'instance-1', + processId: 'workflow1', + state: 'ACTIVE', + nodes: [], + }); + + createExecuteWorkflowAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:execute-workflow', + input: { workflowId: 'workflow1', inputs: { name: 'test' } }, + }); + + expect(result.output).toMatchObject({ instanceId: 'instance-1' }); + expect(mockOrchestratorService.executeWorkflow).toHaveBeenCalled(); + }); + + it('throws InputError when inputs fail schema validation, without executing', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + setUpWorkflow({ + dataInputSchema: 'schema.json', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }); + + createExecuteWorkflowAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:execute-workflow', + input: { workflowId: 'workflow1', inputs: { age: 42 } }, + }), + ).rejects.toThrow(InputError); + expect(mockOrchestratorService.executeWorkflow).not.toHaveBeenCalled(); + }); + + it('falls back to a PENDING status when the new instance is not yet indexed', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + setUpWorkflow(); + (mockOrchestratorService.executeWorkflow as jest.Mock).mockResolvedValue({ + id: 'instance-1', + }); + (mockOrchestratorService.fetchInstance as jest.Mock).mockResolvedValue( + undefined, + ); + + createExecuteWorkflowAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:execute-workflow', + input: { workflowId: 'workflow1', inputs: {} }, + }); + + expect(result.output).toMatchObject({ + instanceId: 'instance-1', + status: 'PENDING', + }); + }); + + it('falls back to a PENDING status and logs a warning when fetching the initial status fails', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + setUpWorkflow(); + (mockOrchestratorService.executeWorkflow as jest.Mock).mockResolvedValue({ + id: 'instance-1', + }); + (mockOrchestratorService.fetchInstance as jest.Mock).mockRejectedValue( + new Error('data index unavailable'), + ); + + createExecuteWorkflowAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:execute-workflow', + input: { workflowId: 'workflow1', inputs: {} }, + }); + + expect(result.output).toMatchObject({ + instanceId: 'instance-1', + status: 'PENDING', + }); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('instance-1'), + ); + }); + + it('throws ServiceUnavailableError when the workflow engine returns no execution response', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + setUpWorkflow(); + (mockOrchestratorService.executeWorkflow as jest.Mock).mockResolvedValue( + undefined, + ); + + createExecuteWorkflowAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:execute-workflow', + input: { workflowId: 'workflow1', inputs: {} }, + }), + ).rejects.toThrow(ServiceUnavailableError); + expect(mockOrchestratorService.fetchInstance).not.toHaveBeenCalled(); + }); + + it('caches the compiled Ajv validator across repeated calls for the same workflow schema', async () => { + const compileSpy = jest.spyOn(Ajv.prototype, 'compile'); + try { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + setUpWorkflow({ + dataInputSchema: 'schema.json', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }); + (mockOrchestratorService.executeWorkflow as jest.Mock).mockResolvedValue({ + id: 'instance-1', + }); + (mockOrchestratorService.fetchInstance as jest.Mock).mockResolvedValue({ + id: 'instance-1', + processId: 'workflow1', + state: 'ACTIVE', + nodes: [], + }); + + createExecuteWorkflowAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + // Prime the cache (may or may not compile, depending on cache state + // left over from other tests sharing this workflowId/schema). + await mockActionsRegistry.invoke({ + id: 'test:execute-workflow', + input: { workflowId: 'workflow1', inputs: { name: 'first' } }, + }); + compileSpy.mockClear(); + + // A second call with the same workflowId/schema must reuse the cached + // validator rather than recompiling - regardless of cache state above. + await expect( + mockActionsRegistry.invoke({ + id: 'test:execute-workflow', + input: { workflowId: 'workflow1', inputs: { age: 42 } }, + }), + ).rejects.toThrow(InputError); + + expect(compileSpy).not.toHaveBeenCalled(); + } finally { + compileSpy.mockRestore(); + } + }); + + it('throws NotFoundError when the workflow does not exist', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + (mockOrchestratorService.fetchWorkflowInfo as jest.Mock).mockResolvedValue( + undefined, + ); + + createExecuteWorkflowAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:execute-workflow', + input: { workflowId: 'missing', inputs: {} }, + }), + ).rejects.toThrow(NotFoundError); + expect(mockOrchestratorService.executeWorkflow).not.toHaveBeenCalled(); + }); + + it('throws NotAllowedError when the use permission is denied', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + createExecuteWorkflowAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:execute-workflow', + input: { workflowId: 'workflow1', inputs: {} }, + }), + ).rejects.toThrow(NotAllowedError); + expect(mockOrchestratorService.fetchWorkflowInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/executeWorkflow.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/executeWorkflow.ts new file mode 100644 index 00000000000..e2665e35380 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/executeWorkflow.ts @@ -0,0 +1,199 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + LoggerService, + PermissionsService, + UserInfoService, +} from '@backstage/backend-plugin-api'; +import type { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { InputError, ServiceUnavailableError } from '@backstage/errors'; +import { ConditionTransformer } from '@backstage/plugin-permission-node'; + +import Ajv, { ValidateFunction } from 'ajv'; +import addFormats from 'ajv-formats'; + +import { orchestratorWorkflowUsePermission } from '@red-hat-developer-hub/backstage-plugin-orchestrator-common'; + +import { OrchestratorService } from '../service/OrchestratorService'; +import { OrchestratorFilters } from '../service/permission-rules'; +import { resolveWorkflowDefinition } from '../service/resolveWorkflowDefinition'; +import * as workflowAuth from '../service/workflowAuthorization'; + +const PENDING_STATUS = 'PENDING'; + +// A single shared Ajv instance, and a small bounded (LRU-ish) cache of +// compiled validators keyed by workflow id + input-schema version, so that +// repeated execute-workflow calls for the same workflow don't recompile the +// schema on every invocation. +const ajv = addFormats(new Ajv({ allErrors: true, strict: false })); +const MAX_CACHED_VALIDATORS = 100; +const validatorCache = new Map(); + +function getOrCompileValidator( + cacheKey: string, + schema: object, +): ValidateFunction { + const cached = validatorCache.get(cacheKey); + if (cached) { + // Re-insert to mark as most-recently-used (Map preserves insertion order). + validatorCache.delete(cacheKey); + validatorCache.set(cacheKey, cached); + return cached; + } + + const validate = ajv.compile(schema); + if (validatorCache.size >= MAX_CACHED_VALIDATORS) { + const oldestKey = validatorCache.keys().next().value; + if (oldestKey !== undefined) { + validatorCache.delete(oldestKey); + } + } + validatorCache.set(cacheKey, validate); + return validate; +} + +export const createExecuteWorkflowAction = ({ + actionsRegistry, + permissions, + userInfo, + orchestratorService, + conditionTransformer, + logger, +}: { + actionsRegistry: ActionsRegistryService; + permissions: PermissionsService; + userInfo: UserInfoService; + orchestratorService: OrchestratorService; + conditionTransformer: ConditionTransformer; + logger: LoggerService; +}) => { + actionsRegistry.register({ + name: 'execute-workflow', + title: 'Execute Workflow', + attributes: { + readOnly: false, + destructive: true, + }, + description: + 'Execute (start a run of) an Orchestrator workflow with the given ' + + 'inputs, returning the new instance ID and its initial status. ' + + 'Call get-workflow-schema first to learn what inputs the workflow ' + + 'expects.', + schema: { + input: z => + z.object({ + workflowId: z + .string() + .describe('The workflow definition ID to execute'), + inputs: z + .record(z.string(), z.unknown()) + .describe( + "The workflow's input parameters, validated against its " + + 'input schema (see get-workflow-schema)', + ), + }), + output: z => + z.object({ + instanceId: z.string().describe('The new workflow instance ID'), + status: z.string().describe('The instance status after execution'), + }), + }, + action: async ({ input, credentials }) => { + const { workflowId, inputs } = input; + + await workflowAuth.authorizeWorkflowAccess( + credentials, + workflowId, + orchestratorWorkflowUsePermission, + conditionTransformer, + permissions, + logger, + ); + + const { serviceUrl, definition } = await resolveWorkflowDefinition( + orchestratorService, + workflowId, + ); + + if (definition.dataInputSchema) { + const infoWithSchema = + await orchestratorService.fetchWorkflowInfoOnService({ + definitionId: workflowId, + serviceUrl, + }); + + if (infoWithSchema?.inputSchema) { + const validate = getOrCompileValidator( + `${workflowId}:${definition.dataInputSchema}`, + infoWithSchema.inputSchema, + ); + + if (!validate(inputs)) { + throw new InputError( + `Invalid inputs for workflow "${workflowId}": ${ajv.errorsText( + validate.errors, + )}`, + ); + } + } + } + + const { userEntityRef: initiatorEntity } = + await userInfo.getUserInfo(credentials); + + const executionResponse = await orchestratorService.executeWorkflow({ + definitionId: workflowId, + serviceUrl, + inputData: { + workflowdata: inputs, + initiatorEntity, + }, + // MCP actions have no raw bearer token to pass through; workflows + // that call back into Backstage as the user are a known Phase 1 + // limitation (see plan section 5). + backstageToken: undefined, + }); + + if (!executionResponse) { + throw new ServiceUnavailableError( + `Failed to execute workflow "${workflowId}"`, + ); + } + + const instanceId = executionResponse.id; + + // Best-effort: report the real initial status if the instance is + // already indexed; fall back to a generic PENDING status rather than + // failing the whole action on a timing race. + let status: string = PENDING_STATUS; + try { + const instance = await orchestratorService.fetchInstance({ + instanceId, + }); + if (instance?.state) { + status = instance.state; + } + } catch (error) { + logger.warn( + `Failed to fetch initial status for instance "${instanceId}": ${error}`, + ); + } + + return { output: { instanceId, status } }; + }, + }); +}; diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/getInstance.test.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/getInstance.test.ts new file mode 100644 index 00000000000..70454a46e23 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/getInstance.test.ts @@ -0,0 +1,266 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockServices } from '@backstage/backend-test-utils'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { NotAllowedError, NotFoundError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +import { OrchestratorService } from '../service/OrchestratorService'; +import { testConditionTransformer as conditionTransformer } from './__fixtures__/testConditionTransformer'; +import { createGetInstanceAction } from './getInstance'; + +// RHIDP-14048: get-instance must return the workflow name, status, start +// time, end time, and output data for a single instance. +describe('createGetInstanceAction', () => { + const logger = mockServices.logger.mock(); + + const mockOrchestratorService = { + fetchInstance: jest.fn(), + } as unknown as OrchestratorService; + + const baseRawInstance = { + id: 'instance-1', + processId: 'my-workflow', + processName: 'My Workflow', + state: 'COMPLETED', + start: '2026-01-01T00:00:00.000Z', + end: '2026-01-01T00:05:00.000Z', + endpoint: 'http://example.com', + nodes: [], + variables: { + initiatorEntity: 'user:default/jdoe', + workflowdata: { result: { message: 'done' } }, + }, + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns the instance name, status, start/end time, and output data when access and ownership checks pass', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + const mockUserInfo = mockServices.userInfo.mock(); + mockUserInfo.getUserInfo.mockResolvedValue({ + userEntityRef: 'user:default/jdoe', + ownershipEntityRefs: [], + }); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + (mockOrchestratorService.fetchInstance as jest.Mock).mockResolvedValue( + baseRawInstance, + ); + + createGetInstanceAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:get-instance', + input: { instanceId: 'instance-1' }, + }); + + expect(result.output).toMatchObject({ + instanceId: 'instance-1', + workflowId: 'my-workflow', + workflowName: 'My Workflow', + status: 'COMPLETED', + startTime: '2026-01-01T00:00:00.000Z', + endTime: '2026-01-01T00:05:00.000Z', + output: { result: { message: 'done' } }, + }); + }); + + it('returns only the required fields when optional data is absent', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + const mockUserInfo = mockServices.userInfo.mock(); + mockUserInfo.getUserInfo.mockResolvedValue({ + userEntityRef: 'user:default/jdoe', + ownershipEntityRefs: [], + }); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + (mockOrchestratorService.fetchInstance as jest.Mock).mockResolvedValue({ + id: 'instance-1', + processId: 'my-workflow', + nodes: [], + variables: { initiatorEntity: 'user:default/jdoe' }, + }); + + createGetInstanceAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:get-instance', + input: { instanceId: 'instance-1' }, + }); + + expect(result.output).toMatchObject({ + instanceId: 'instance-1', + workflowId: 'my-workflow', + workflowName: undefined, + output: undefined, + }); + }); + + it('throws NotFoundError when the instance does not exist', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + const mockUserInfo = mockServices.userInfo.mock(); + (mockOrchestratorService.fetchInstance as jest.Mock).mockResolvedValue( + undefined, + ); + + createGetInstanceAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:get-instance', + input: { instanceId: 'missing' }, + }), + ).rejects.toThrow(NotFoundError); + }); + + it('throws NotAllowedError when workflow access is denied', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + const mockUserInfo = mockServices.userInfo.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + // No conditional match and no deprecated per-workflow fallback granted. + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + (mockOrchestratorService.fetchInstance as jest.Mock).mockResolvedValue( + baseRawInstance, + ); + + createGetInstanceAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:get-instance', + input: { instanceId: 'instance-1' }, + }), + ).rejects.toThrow(NotAllowedError); + }); + + it('throws NotAllowedError when the instance was initiated by someone else', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + const mockUserInfo = mockServices.userInfo.mock(); + mockUserInfo.getUserInfo.mockResolvedValue({ + userEntityRef: 'user:default/someone-else', + ownershipEntityRefs: [], + }); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + (mockOrchestratorService.fetchInstance as jest.Mock).mockResolvedValue( + baseRawInstance, + ); + + createGetInstanceAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:get-instance', + input: { instanceId: 'instance-1' }, + }), + ).rejects.toThrow(NotAllowedError); + }); + + it('allows viewing another user instance when admin view permission is granted', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + const mockUserInfo = mockServices.userInfo.mock(); + mockUserInfo.getUserInfo.mockResolvedValue({ + userEntityRef: 'user:default/someone-else', + ownershipEntityRefs: [], + }); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + (mockOrchestratorService.fetchInstance as jest.Mock).mockResolvedValue( + baseRawInstance, + ); + + createGetInstanceAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:get-instance', + input: { instanceId: 'instance-1' }, + }); + + expect(result.output).toMatchObject({ instanceId: 'instance-1' }); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/getInstance.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/getInstance.ts new file mode 100644 index 00000000000..7bdcc02b3cd --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/getInstance.ts @@ -0,0 +1,129 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + LoggerService, + PermissionsService, + UserInfoService, +} from '@backstage/backend-plugin-api'; +import type { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { NotFoundError } from '@backstage/errors'; +import { ConditionTransformer } from '@backstage/plugin-permission-node'; + +import { orchestratorWorkflowPermission } from '@red-hat-developer-hub/backstage-plugin-orchestrator-common'; + +import { mapToProcessInstanceDTO } from '../service/api/mapping/V2Mappings'; +import { OrchestratorService } from '../service/OrchestratorService'; +import { OrchestratorFilters } from '../service/permission-rules'; +import * as workflowAuth from '../service/workflowAuthorization'; + +export const createGetInstanceAction = ({ + actionsRegistry, + permissions, + userInfo, + orchestratorService, + conditionTransformer, + logger, +}: { + actionsRegistry: ActionsRegistryService; + permissions: PermissionsService; + userInfo: UserInfoService; + orchestratorService: OrchestratorService; + conditionTransformer: ConditionTransformer; + logger: LoggerService; +}) => { + actionsRegistry.register({ + name: 'get-instance', + title: 'Get Workflow Instance', + attributes: { + readOnly: true, + }, + description: + 'Fetch a single Orchestrator workflow run (instance) by its instance ID, ' + + 'returning its workflow name, status, start time, end time, and output data.', + schema: { + input: z => + z.object({ + instanceId: z + .string() + .describe('The workflow instance (process) ID to fetch'), + }), + output: z => + z.object({ + instanceId: z.string().describe('The workflow instance ID'), + workflowId: z.string().describe('The workflow definition ID'), + workflowName: z + .string() + .optional() + .describe('The human-readable workflow name'), + status: z.string().optional().describe('The instance status'), + startTime: z + .string() + .optional() + .describe('When the instance started, as an ISO timestamp'), + endTime: z + .string() + .optional() + .describe('When the instance ended, as an ISO timestamp'), + output: z + .record(z.string(), z.unknown()) + .optional() + .describe('The workflow output data'), + }), + }, + action: async ({ input, credentials }) => { + const rawInstance = await orchestratorService.fetchInstance({ + instanceId: input.instanceId, + }); + + if (!rawInstance) { + throw new NotFoundError(`Instance "${input.instanceId}" not found`); + } + + const instance = mapToProcessInstanceDTO(rawInstance); + const workflowId = instance.processId; + + await workflowAuth.authorizeWorkflowAccess( + credentials, + workflowId, + orchestratorWorkflowPermission, + conditionTransformer, + permissions, + logger, + ); + + await workflowAuth.assertInstanceOwnership( + credentials, + permissions, + userInfo, + instance, + input.instanceId, + ); + + return { + output: { + instanceId: instance.id, + workflowId: instance.processId, + workflowName: instance.processName, + status: instance.state, + startTime: instance.start, + endTime: instance.end, + output: instance.workflowdata as Record | undefined, + }, + }; + }, + }); +}; diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/getWorkflowSchema.test.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/getWorkflowSchema.test.ts new file mode 100644 index 00000000000..62ff2eb40f2 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/getWorkflowSchema.test.ts @@ -0,0 +1,227 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockServices } from '@backstage/backend-test-utils'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { NotAllowedError, NotFoundError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +import { OrchestratorService } from '../service/OrchestratorService'; +import { testConditionTransformer as conditionTransformer } from './__fixtures__/testConditionTransformer'; +import { createGetWorkflowSchemaAction } from './getWorkflowSchema'; + +// RHIDP-14045: get-workflow-schema must return the workflow's input JSON +// schema (required/optional params) given a workflow ID, enforce the read +// permission, and explicitly document itself as a prerequisite for +// execute-workflow. +describe('createGetWorkflowSchemaAction', () => { + const logger = mockServices.logger.mock(); + + const mockOrchestratorService = { + fetchWorkflowInfo: jest.fn(), + fetchWorkflowDefinition: jest.fn(), + fetchWorkflowInfoOnService: jest.fn(), + } as unknown as OrchestratorService; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + function allowAccess( + mockPermissions: ReturnType, + ) { + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + } + + it('describes itself as a prerequisite for execute-workflow', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + + createGetWorkflowSchemaAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const { actions } = await mockActionsRegistry.list(); + const action = actions.find(a => a.name === 'get-workflow-schema'); + expect(action?.description.toLowerCase()).toContain('execute-workflow'); + }); + + it('returns the input JSON schema for the workflow', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + (mockOrchestratorService.fetchWorkflowInfo as jest.Mock).mockResolvedValue({ + id: 'workflow1', + serviceUrl: 'http://svc', + }); + ( + mockOrchestratorService.fetchWorkflowDefinition as jest.Mock + ).mockResolvedValue({ + dataInputSchema: 'schema.json', + }); + ( + mockOrchestratorService.fetchWorkflowInfoOnService as jest.Mock + ).mockResolvedValue({ + id: 'workflow1', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }); + + createGetWorkflowSchemaAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:get-workflow-schema', + input: { workflowId: 'workflow1' }, + }); + + expect(result.output).toMatchObject({ + workflowId: 'workflow1', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }); + }); + + it('returns an empty input schema when the workflow has no dataInputSchema', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + (mockOrchestratorService.fetchWorkflowInfo as jest.Mock).mockResolvedValue({ + id: 'workflow1', + serviceUrl: 'http://svc', + }); + ( + mockOrchestratorService.fetchWorkflowDefinition as jest.Mock + ).mockResolvedValue({}); + + createGetWorkflowSchemaAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:get-workflow-schema', + input: { workflowId: 'workflow1' }, + }); + + expect(result.output).toMatchObject({ + workflowId: 'workflow1', + inputSchema: {}, + }); + expect( + mockOrchestratorService.fetchWorkflowInfoOnService, + ).not.toHaveBeenCalled(); + }); + + it('throws NotFoundError when the workflow does not exist', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + (mockOrchestratorService.fetchWorkflowInfo as jest.Mock).mockResolvedValue( + undefined, + ); + + createGetWorkflowSchemaAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:get-workflow-schema', + input: { workflowId: 'missing' }, + }), + ).rejects.toThrow(NotFoundError); + }); + + it('throws NotFoundError when the workflow definition cannot be fetched', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + (mockOrchestratorService.fetchWorkflowInfo as jest.Mock).mockResolvedValue({ + id: 'workflow1', + serviceUrl: 'http://svc', + }); + ( + mockOrchestratorService.fetchWorkflowDefinition as jest.Mock + ).mockResolvedValue(undefined); + + createGetWorkflowSchemaAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:get-workflow-schema', + input: { workflowId: 'workflow1' }, + }), + ).rejects.toThrow(NotFoundError); + }); + + it('throws NotAllowedError when workflow access is denied', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + createGetWorkflowSchemaAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:get-workflow-schema', + input: { workflowId: 'workflow1' }, + }), + ).rejects.toThrow(NotAllowedError); + expect(mockOrchestratorService.fetchWorkflowInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/getWorkflowSchema.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/getWorkflowSchema.ts new file mode 100644 index 00000000000..963ff6abfcb --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/getWorkflowSchema.ts @@ -0,0 +1,108 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + LoggerService, + PermissionsService, +} from '@backstage/backend-plugin-api'; +import type { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { ConditionTransformer } from '@backstage/plugin-permission-node'; + +import { orchestratorWorkflowPermission } from '@red-hat-developer-hub/backstage-plugin-orchestrator-common'; + +import { OrchestratorService } from '../service/OrchestratorService'; +import { OrchestratorFilters } from '../service/permission-rules'; +import { resolveWorkflowDefinition } from '../service/resolveWorkflowDefinition'; +import * as workflowAuth from '../service/workflowAuthorization'; + +export const createGetWorkflowSchemaAction = ({ + actionsRegistry, + permissions, + orchestratorService, + conditionTransformer, + logger, +}: { + actionsRegistry: ActionsRegistryService; + permissions: PermissionsService; + orchestratorService: OrchestratorService; + conditionTransformer: ConditionTransformer; + logger: LoggerService; +}) => { + actionsRegistry.register({ + name: 'get-workflow-schema', + title: 'Get Workflow Input Schema', + attributes: { + readOnly: true, + }, + description: + 'Fetch the input JSON schema (required and optional parameters) for an ' + + 'Orchestrator workflow definition. Call this before execute-workflow ' + + 'to learn what inputs the workflow accepts.', + schema: { + input: z => + z.object({ + workflowId: z + .string() + .describe('The workflow definition ID to fetch the schema for'), + }), + output: z => + z.object({ + workflowId: z.string().describe('The workflow definition ID'), + inputSchema: z + .record(z.string(), z.unknown()) + .describe( + "The workflow's input JSON schema, or an empty object if the " + + 'workflow does not declare one', + ), + }), + }, + action: async ({ input, credentials }) => { + const { workflowId } = input; + + await workflowAuth.authorizeWorkflowAccess( + credentials, + workflowId, + orchestratorWorkflowPermission, + conditionTransformer, + permissions, + logger, + ); + + const { serviceUrl, definition } = await resolveWorkflowDefinition( + orchestratorService, + workflowId, + ); + + if (!definition.dataInputSchema) { + return { output: { workflowId, inputSchema: {} } }; + } + + const infoWithSchema = + await orchestratorService.fetchWorkflowInfoOnService({ + definitionId: workflowId, + serviceUrl, + }); + + return { + output: { + workflowId, + inputSchema: + (infoWithSchema?.inputSchema as Record) ?? {}, + }, + }; + }, + }); +}; diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/index.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/index.ts new file mode 100644 index 00000000000..dc73977ece4 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/index.ts @@ -0,0 +1,56 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + LoggerService, + PermissionsService, + UserInfoService, +} from '@backstage/backend-plugin-api'; +import type { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { ConditionTransformer } from '@backstage/plugin-permission-node'; + +import { OrchestratorService } from '../service/OrchestratorService'; +import { OrchestratorFilters } from '../service/permission-rules'; +import { createExecuteWorkflowAction } from './executeWorkflow'; +import { createGetInstanceAction } from './getInstance'; +import { createGetWorkflowSchemaAction } from './getWorkflowSchema'; +import { createListInstancesAction } from './listInstances'; +import { createListWorkflowsAction } from './listWorkflows'; + +export { createExecuteWorkflowAction } from './executeWorkflow'; +export { createGetInstanceAction } from './getInstance'; +export { createGetWorkflowSchemaAction } from './getWorkflowSchema'; +export { createListInstancesAction } from './listInstances'; +export { createListWorkflowsAction } from './listWorkflows'; + +/** + * Registers all 5 Orchestrator MCP actions (RHIDP-14041): list-workflows, + * get-workflow-schema, execute-workflow, list-instances, get-instance. + */ +export const createOrchestratorActions = (options: { + actionsRegistry: ActionsRegistryService; + permissions: PermissionsService; + userInfo: UserInfoService; + orchestratorService: OrchestratorService; + conditionTransformer: ConditionTransformer; + logger: LoggerService; +}) => { + createGetInstanceAction(options); + createListWorkflowsAction(options); + createGetWorkflowSchemaAction(options); + createExecuteWorkflowAction(options); + createListInstancesAction(options); +}; diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/listInstances.test.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/listInstances.test.ts new file mode 100644 index 00000000000..084cc2d473a --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/listInstances.test.ts @@ -0,0 +1,374 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockServices } from '@backstage/backend-test-utils'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { NotAllowedError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +import { OrchestratorService } from '../service/OrchestratorService'; +import { testConditionTransformer as conditionTransformer } from './__fixtures__/testConditionTransformer'; +import { createListInstancesAction } from './listInstances'; + +// RHIDP-14047: list-instances must support an optional status filter, +// output instance ids/workflow names/statuses/timestamps, enforce the read +// permission, restrict non-admin callers to their own instances, and throw +// NotAllowedError when access is denied outright. +describe('createListInstancesAction', () => { + const logger = mockServices.logger.mock(); + const mockUserInfo = mockServices.userInfo.mock(); + + const mockOrchestratorService = { + getWorkflowIds: jest.fn(), + fetchInstances: jest.fn(), + } as unknown as OrchestratorService; + + const rawInstances = [ + { + id: 'instance-1', + processId: 'workflow1', + processName: 'Onboard Employee', + state: 'ACTIVE', + start: '2026-01-01T00:00:00.000Z', + endpoint: 'http://example.com', + nodes: [], + variables: { initiatorEntity: 'user:default/jdoe' }, + }, + { + id: 'instance-2', + processId: 'workflow2', + processName: 'Offboard Employee', + state: 'COMPLETED', + start: '2026-01-02T00:00:00.000Z', + end: '2026-01-02T00:05:00.000Z', + endpoint: 'http://example.com', + nodes: [], + variables: { initiatorEntity: 'user:default/someone-else' }, + }, + ]; + + beforeEach(() => { + jest.resetAllMocks(); + mockUserInfo.getUserInfo.mockResolvedValue({ + userEntityRef: 'user:default/jdoe', + ownershipEntityRefs: [], + }); + (mockOrchestratorService.getWorkflowIds as jest.Mock).mockReturnValue([ + 'workflow1', + 'workflow2', + ]); + }); + + function allowAccess( + mockPermissions: ReturnType, + ) { + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + } + + it('returns instance ids, workflow names, statuses, and timestamps', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); // not admin-view + (mockOrchestratorService.fetchInstances as jest.Mock).mockResolvedValue( + rawInstances, + ); + + createListInstancesAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-instances', + input: {}, + }); + + expect(result.output).toMatchObject({ + instances: [ + { + instanceId: 'instance-1', + workflowId: 'workflow1', + workflowName: 'Onboard Employee', + status: 'ACTIVE', + startTime: '2026-01-01T00:00:00.000Z', + }, + { + instanceId: 'instance-2', + workflowId: 'workflow2', + workflowName: 'Offboard Employee', + status: 'COMPLETED', + startTime: '2026-01-02T00:00:00.000Z', + endTime: '2026-01-02T00:05:00.000Z', + }, + ], + }); + }); + + it('restricts non-admin callers to their own instances via an ownership filter', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); // not admin-view + (mockOrchestratorService.fetchInstances as jest.Mock).mockResolvedValue([]); + + createListInstancesAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await mockActionsRegistry.invoke({ + id: 'test:list-instances', + input: {}, + }); + + expect(mockOrchestratorService.fetchInstances).toHaveBeenCalledWith( + expect.objectContaining({ + workflowIds: ['workflow1', 'workflow2'], + filter: { + field: 'variables', + nested: { + operator: 'EQ', + value: 'user:default/jdoe', + field: 'initiatorEntity', + }, + }, + }), + ); + }); + + it('does not apply an ownership filter for callers with instance admin-view permission', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); // admin-view granted + (mockOrchestratorService.fetchInstances as jest.Mock).mockResolvedValue([]); + + createListInstancesAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await mockActionsRegistry.invoke({ + id: 'test:list-instances', + input: {}, + }); + + expect(mockOrchestratorService.fetchInstances).toHaveBeenCalledWith( + expect.objectContaining({ + workflowIds: ['workflow1', 'workflow2'], + filter: undefined, + }), + ); + }); + + it('filters by status', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); // admin-view granted, to isolate the status filter alone + (mockOrchestratorService.fetchInstances as jest.Mock).mockResolvedValue([]); + + createListInstancesAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await mockActionsRegistry.invoke({ + id: 'test:list-instances', + input: { status: 'ACTIVE' }, + }); + + expect(mockOrchestratorService.fetchInstances).toHaveBeenCalledWith( + expect.objectContaining({ + filter: { field: 'state', operator: 'EQ', value: 'ACTIVE' }, + }), + ); + }); + + it('defaults to a bounded page (limit 50, offset 0) when pagination is not specified', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + (mockOrchestratorService.fetchInstances as jest.Mock).mockResolvedValue([]); + + createListInstancesAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await mockActionsRegistry.invoke({ + id: 'test:list-instances', + input: {}, + }); + + expect(mockOrchestratorService.fetchInstances).toHaveBeenCalledWith( + expect.objectContaining({ + pagination: { limit: 50, offset: 0 }, + }), + ); + }); + + it('passes through caller-supplied limit and offset', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + allowAccess(mockPermissions); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + (mockOrchestratorService.fetchInstances as jest.Mock).mockResolvedValue([]); + + createListInstancesAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await mockActionsRegistry.invoke({ + id: 'test:list-instances', + input: { limit: 10, offset: 20 }, + }); + + expect(mockOrchestratorService.fetchInstances).toHaveBeenCalledWith( + expect.objectContaining({ + pagination: { limit: 10, offset: 20 }, + }), + ); + }); + + it('rejects a limit greater than the maximum page size', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + + createListInstancesAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:list-instances', + input: { limit: 500 }, + }), + ).rejects.toThrow(/Invalid input/); + expect(mockOrchestratorService.fetchInstances).not.toHaveBeenCalled(); + }); + + it('returns an empty array without querying instances when no workflows are authorized', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + // Conditionally allowed, but for a workflow that isn't in the cache - + // i.e. the caller has *some* access, just not to any known workflow. + mockPermissions.authorizeConditional.mockResolvedValue([ + { + result: AuthorizeResult.CONDITIONAL, + pluginId: 'orchestrator', + resourceType: 'orchestrator-workflow', + conditions: { + rule: 'IS_ALLOWED_WORKFLOW_ID', + resourceType: 'orchestrator-workflow', + params: { workflowIds: ['workflow999'] }, + }, + }, + ]); + // No deprecated per-workflow fallback for workflow1/workflow2 either. + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + createListInstancesAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-instances', + input: {}, + }); + + expect(result.output).toMatchObject({ instances: [] }); + expect(mockOrchestratorService.fetchInstances).not.toHaveBeenCalled(); + }); + + it('throws NotAllowedError when the read permission is denied outright', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + createListInstancesAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + userInfo: mockUserInfo, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:list-instances', + input: {}, + }), + ).rejects.toThrow(NotAllowedError); + expect(mockOrchestratorService.getWorkflowIds).not.toHaveBeenCalled(); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/listInstances.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/listInstances.ts new file mode 100644 index 00000000000..838c5fc1b4c --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/listInstances.ts @@ -0,0 +1,179 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + LoggerService, + PermissionsService, + UserInfoService, +} from '@backstage/backend-plugin-api'; +import type { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { ConditionTransformer } from '@backstage/plugin-permission-node'; + +import { + Filter, + orchestratorWorkflowPermission, + ProcessInstanceState, +} from '@red-hat-developer-hub/backstage-plugin-orchestrator-common'; + +import { mapToProcessInstanceDTO } from '../service/api/mapping/V2Mappings'; +import { OrchestratorService } from '../service/OrchestratorService'; +import { OrchestratorFilters } from '../service/permission-rules'; +import * as workflowAuth from '../service/workflowAuthorization'; +import { Pagination } from '../types/pagination'; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; + +export const createListInstancesAction = ({ + actionsRegistry, + permissions, + userInfo, + orchestratorService, + conditionTransformer, + logger, +}: { + actionsRegistry: ActionsRegistryService; + permissions: PermissionsService; + userInfo: UserInfoService; + orchestratorService: OrchestratorService; + conditionTransformer: ConditionTransformer; + logger: LoggerService; +}) => { + actionsRegistry.register({ + name: 'list-instances', + title: 'List Workflow Instances', + attributes: { + readOnly: true, + }, + description: + 'List Orchestrator workflow runs (instances) visible to the caller, ' + + 'optionally filtered by status. Callers without the ' + + "'orchestrator.instanceAdminView' permission only see instances they " + + 'initiated themselves.', + schema: { + input: z => + z.object({ + status: z + .nativeEnum(ProcessInstanceState) + .optional() + .describe('Filter instances by their current status'), + limit: z + .number() + .int() + .min(1) + .max(MAX_LIMIT) + .optional() + .describe( + `Maximum number of instances to return (1-${MAX_LIMIT}, default ${DEFAULT_LIMIT})`, + ), + offset: z + .number() + .int() + .min(0) + .optional() + .describe( + 'Number of instances to skip, for paging through results (default 0)', + ), + }), + output: z => + z.object({ + instances: z.array( + z.object({ + instanceId: z.string().describe('The workflow instance ID'), + workflowId: z.string().describe('The workflow definition ID'), + workflowName: z + .string() + .optional() + .describe('The human-readable workflow name'), + status: z.string().optional().describe('The instance status'), + startTime: z + .string() + .optional() + .describe('When the instance started, as an ISO timestamp'), + endTime: z + .string() + .optional() + .describe('When the instance ended, as an ISO timestamp'), + }), + ), + }), + }, + action: async ({ input, credentials }) => { + await workflowAuth.assertAnyWorkflowAccess( + credentials, + permissions, + orchestratorWorkflowPermission, + ); + + const allWorkflowIds = orchestratorService.getWorkflowIds(); + const authorizedWorkflowIds = + await workflowAuth.filterAuthorizedWorkflowIds( + credentials, + permissions, + allWorkflowIds, + conditionTransformer, + logger, + ); + + if (authorizedWorkflowIds.length === 0) { + return { output: { instances: [] } }; + } + + const isAdminView = + await workflowAuth.isUserAuthorizedForInstanceAdminViewPermission( + credentials, + permissions, + ); + + let filter: Filter | undefined = input.status + ? { field: 'state', operator: 'EQ', value: input.status } + : undefined; + + if (!isAdminView) { + const { userEntityRef: initiatorEntity } = + await userInfo.getUserInfo(credentials); + filter = workflowAuth.buildInstanceOwnershipFilter( + initiatorEntity, + filter, + ); + } + + const pagination: Pagination = { + limit: input.limit ?? DEFAULT_LIMIT, + offset: input.offset ?? 0, + }; + + const rawInstances = await orchestratorService.fetchInstances({ + filter, + workflowIds: authorizedWorkflowIds, + pagination, + }); + + const instances = rawInstances + .map(mapToProcessInstanceDTO) + .map(instance => ({ + instanceId: instance.id, + workflowId: instance.processId, + workflowName: instance.processName, + status: instance.state, + startTime: instance.start, + endTime: instance.end, + })); + + return { output: { instances } }; + }, + }); +}; diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/listWorkflows.test.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/listWorkflows.test.ts new file mode 100644 index 00000000000..fac2b60f1ba --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/listWorkflows.test.ts @@ -0,0 +1,300 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { mockServices } from '@backstage/backend-test-utils'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; +import { NotAllowedError } from '@backstage/errors'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +import { OrchestratorService } from '../service/OrchestratorService'; +import { testConditionTransformer as conditionTransformer } from './__fixtures__/testConditionTransformer'; +import { createListWorkflowsAction } from './listWorkflows'; + +// RHIDP-14044: list-workflows must support optional name/status filters, +// output workflow names/IDs/statuses, enforce the read permission, and +// throw NotAllowedError when access is denied outright. +describe('createListWorkflowsAction', () => { + const logger = mockServices.logger.mock(); + + const overviews = [ + { + workflowId: 'workflow1', + name: 'Onboard Employee', + lastRunStatus: 'ACTIVE', + }, + { + workflowId: 'workflow2', + name: 'Offboard Employee', + lastRunStatus: 'COMPLETED', + }, + { workflowId: 'workflow3', name: 'Provision VM', lastRunStatus: 'ERROR' }, + ]; + + const mockOrchestratorService = { + fetchWorkflowOverviews: jest.fn(), + } as unknown as OrchestratorService; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns workflow ids, names, and statuses when access is fully allowed', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + ( + mockOrchestratorService.fetchWorkflowOverviews as jest.Mock + ).mockResolvedValue(overviews); + + createListWorkflowsAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-workflows', + input: {}, + }); + + expect(result.output).toMatchObject({ + workflows: [ + { workflowId: 'workflow1', name: 'Onboard Employee', status: 'ACTIVE' }, + { + workflowId: 'workflow2', + name: 'Offboard Employee', + status: 'COMPLETED', + }, + { workflowId: 'workflow3', name: 'Provision VM', status: 'ERROR' }, + ], + }); + }); + + it('filters by name (case-insensitive substring match)', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + ( + mockOrchestratorService.fetchWorkflowOverviews as jest.Mock + ).mockResolvedValue(overviews); + + createListWorkflowsAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-workflows', + input: { name: 'employee' }, + }); + + expect(result.output).toMatchObject({ + workflows: [{ workflowId: 'workflow1' }, { workflowId: 'workflow2' }], + }); + expect((result.output as { workflows: unknown[] }).workflows).toHaveLength( + 2, + ); + }); + + it('filters by status', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + ( + mockOrchestratorService.fetchWorkflowOverviews as jest.Mock + ).mockResolvedValue(overviews); + + createListWorkflowsAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-workflows', + input: { status: 'ERROR' }, + }); + + expect(result.output).toMatchObject({ + workflows: [{ workflowId: 'workflow3', status: 'ERROR' }], + }); + expect((result.output as { workflows: unknown[] }).workflows).toHaveLength( + 1, + ); + }); + + it('filters out workflows not covered by a conditional decision', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { + result: AuthorizeResult.CONDITIONAL, + pluginId: 'orchestrator', + resourceType: 'orchestrator-workflow', + conditions: { + rule: 'IS_ALLOWED_WORKFLOW_ID', + resourceType: 'orchestrator-workflow', + params: { workflowIds: ['workflow1'] }, + }, + }, + ]); + // No deprecated per-workflow fallback for the remaining workflows. + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + ( + mockOrchestratorService.fetchWorkflowOverviews as jest.Mock + ).mockResolvedValue(overviews); + + createListWorkflowsAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:list-workflows', + input: {}, + }); + + expect(result.output).toMatchObject({ + workflows: [{ workflowId: 'workflow1' }], + }); + expect((result.output as { workflows: unknown[] }).workflows).toHaveLength( + 1, + ); + }); + + it('defaults to a bounded page (limit 50, offset 0) when pagination is not specified', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + ( + mockOrchestratorService.fetchWorkflowOverviews as jest.Mock + ).mockResolvedValue(overviews); + + createListWorkflowsAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await mockActionsRegistry.invoke({ + id: 'test:list-workflows', + input: {}, + }); + + expect(mockOrchestratorService.fetchWorkflowOverviews).toHaveBeenCalledWith( + { pagination: { limit: 50, offset: 0 } }, + ); + }); + + it('passes through caller-supplied limit and offset', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + ( + mockOrchestratorService.fetchWorkflowOverviews as jest.Mock + ).mockResolvedValue(overviews); + + createListWorkflowsAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await mockActionsRegistry.invoke({ + id: 'test:list-workflows', + input: { limit: 5, offset: 15 }, + }); + + expect(mockOrchestratorService.fetchWorkflowOverviews).toHaveBeenCalledWith( + { pagination: { limit: 5, offset: 15 } }, + ); + }); + + it('rejects a limit greater than the maximum page size', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + + createListWorkflowsAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:list-workflows', + input: { limit: 500 }, + }), + ).rejects.toThrow(/Invalid input/); + expect( + mockOrchestratorService.fetchWorkflowOverviews, + ).not.toHaveBeenCalled(); + }); + + it('throws NotAllowedError when the read permission is denied outright', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + createListWorkflowsAction({ + actionsRegistry: mockActionsRegistry, + permissions: mockPermissions, + orchestratorService: mockOrchestratorService, + conditionTransformer, + logger, + }); + + await expect( + mockActionsRegistry.invoke({ + id: 'test:list-workflows', + input: {}, + }), + ).rejects.toThrow(NotAllowedError); + expect( + mockOrchestratorService.fetchWorkflowOverviews, + ).not.toHaveBeenCalled(); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/listWorkflows.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/listWorkflows.ts new file mode 100644 index 00000000000..8090fe3060d --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/actions/listWorkflows.ts @@ -0,0 +1,153 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + LoggerService, + PermissionsService, +} from '@backstage/backend-plugin-api'; +import type { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { ConditionTransformer } from '@backstage/plugin-permission-node'; + +import { + orchestratorWorkflowPermission, + ProcessInstanceState, +} from '@red-hat-developer-hub/backstage-plugin-orchestrator-common'; + +import { OrchestratorService } from '../service/OrchestratorService'; +import { OrchestratorFilters } from '../service/permission-rules'; +import * as workflowAuth from '../service/workflowAuthorization'; +import { Pagination } from '../types/pagination'; + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; + +export const createListWorkflowsAction = ({ + actionsRegistry, + permissions, + orchestratorService, + conditionTransformer, + logger, +}: { + actionsRegistry: ActionsRegistryService; + permissions: PermissionsService; + orchestratorService: OrchestratorService; + conditionTransformer: ConditionTransformer; + logger: LoggerService; +}) => { + actionsRegistry.register({ + name: 'list-workflows', + title: 'List Workflows', + attributes: { + readOnly: true, + }, + description: + 'List the Orchestrator workflow definitions visible to the caller, ' + + 'optionally filtered by name (case-insensitive substring match) ' + + "and/or the workflow's last run status.", + schema: { + input: z => + z.object({ + name: z + .string() + .optional() + .describe( + 'Filter workflows whose name contains this substring (case-insensitive)', + ), + status: z + .nativeEnum(ProcessInstanceState) + .optional() + .describe("Filter workflows by their last run's status"), + limit: z + .number() + .int() + .min(1) + .max(MAX_LIMIT) + .optional() + .describe( + `Maximum number of workflows to return (1-${MAX_LIMIT}, default ${DEFAULT_LIMIT})`, + ), + offset: z + .number() + .int() + .min(0) + .optional() + .describe( + 'Number of workflows to skip, for paging through results (default 0)', + ), + }), + output: z => + z.object({ + workflows: z.array( + z.object({ + workflowId: z.string().describe('The workflow definition ID'), + name: z + .string() + .optional() + .describe('The human-readable workflow name'), + status: z + .string() + .optional() + .describe("The workflow's last run status"), + }), + ), + }), + }, + action: async ({ input, credentials }) => { + await workflowAuth.assertAnyWorkflowAccess( + credentials, + permissions, + orchestratorWorkflowPermission, + ); + + const pagination: Pagination = { + limit: input.limit ?? DEFAULT_LIMIT, + offset: input.offset ?? 0, + }; + + const overviews = + (await orchestratorService.fetchWorkflowOverviews({ pagination })) ?? + []; + + const authorizedIds = new Set( + await workflowAuth.filterAuthorizedWorkflowIds( + credentials, + permissions, + overviews.map(overview => overview.workflowId), + conditionTransformer, + logger, + ), + ); + + const workflows = overviews + .filter(overview => authorizedIds.has(overview.workflowId)) + .filter( + overview => + !input.name || + overview.name?.toLowerCase().includes(input.name.toLowerCase()), + ) + .filter( + overview => !input.status || overview.lastRunStatus === input.status, + ) + .map(overview => ({ + workflowId: overview.workflowId, + name: overview.name, + status: overview.lastRunStatus, + })); + + return { output: { workflows } }; + }, + }); +}; diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/mcp-tools.integration.test.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/mcp-tools.integration.test.ts new file mode 100644 index 00000000000..c9e95b697c2 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/mcp-tools.integration.test.ts @@ -0,0 +1,275 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + CallToolResultSchema, + ListToolsResultSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +import { + mockOrchestratorService, + parseCallToolError, + parseCallToolOutput, + startMcpBackend, + withMcpClient, +} from './__testUtils__/mcpTestUtils'; + +/** + * Pyramid invariant (RHIDP-14041): the per-action unit tests + * (`actions/*.test.ts`) already prove each action's branching/mapping logic + * against mocked services. This suite proves the remaining, un-mocked + * concern - that all 5 actions are actually *wired*: registered by the real + * `orchestratorPlugin`, reachable through the real `@backstage/plugin-mcp-actions-backend` + * plugin, and callable end-to-end by a real `@modelcontextprotocol/sdk` + * client - not just invokable directly against `ActionsRegistryService` + * mocks. Mirrors Scorecard PR #3332's `mcp-tools.integration.test.ts`. + */ + +type McpTestBackend = Awaited>; + +const ORCHESTRATOR_TOOL_NAMES = [ + 'orchestrator.list-workflows', + 'orchestrator.get-workflow-schema', + 'orchestrator.execute-workflow', + 'orchestrator.list-instances', + 'orchestrator.get-instance', +] as const; + +const READ_ONLY_TOOL_NAMES = [ + 'orchestrator.list-workflows', + 'orchestrator.get-workflow-schema', + 'orchestrator.list-instances', + 'orchestrator.get-instance', +] as const; + +function rawInstance(overrides: Record = {}) { + return { + id: 'instance1', + processId: 'workflow1', + processName: 'Onboard Employee', + nodes: [], + state: 'ACTIVE', + start: '2024-01-01T00:00:00.000Z', + variables: { + workflowdata: { foo: 'bar' }, + initiatorEntity: 'user:default/test', + }, + ...overrides, + }; +} + +describe('Orchestrator MCP tools integration', () => { + let allowedBackend: McpTestBackend; + let deniedBackend: McpTestBackend; + + beforeAll(async () => { + allowedBackend = await startMcpBackend({ permissionMode: 'allow-all' }); + deniedBackend = await startMcpBackend({ permissionMode: 'deny-all' }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('exposes all 5 orchestrator tools through MCP tools/list', async () => { + await withMcpClient(allowedBackend.server, async client => { + const result = await client.request( + { method: 'tools/list' }, + ListToolsResultSchema, + ); + + const toolNames = result.tools.map(tool => tool.name); + expect(toolNames).toEqual( + expect.arrayContaining(ORCHESTRATOR_TOOL_NAMES), + ); + }); + }); + + it('marks the 4 read actions read-only and execute-workflow not read-only', async () => { + await withMcpClient(allowedBackend.server, async client => { + const result = await client.request( + { method: 'tools/list' }, + ListToolsResultSchema, + ); + + const toolsByName = Object.fromEntries( + result.tools.map(tool => [tool.name, tool]), + ); + + for (const toolName of READ_ONLY_TOOL_NAMES) { + expect(toolsByName[toolName]?.annotations?.readOnlyHint).toBe(true); + } + expect( + toolsByName['orchestrator.execute-workflow']?.annotations?.readOnlyHint, + ).toBe(false); + }); + }); + + it('calls orchestrator.list-workflows and returns configured workflows', async () => { + mockOrchestratorService.fetchWorkflowOverviews.mockResolvedValue([ + { workflowId: 'workflow1', name: 'Onboard Employee' }, + ]); + + await withMcpClient(allowedBackend.server, async client => { + const result = await client.callTool( + { name: 'orchestrator.list-workflows', arguments: {} }, + CallToolResultSchema, + ); + + const output = parseCallToolOutput(result) as { + workflows: Array<{ workflowId: string }>; + }; + + expect(result.isError).not.toBe(true); + expect(output.workflows.map(w => w.workflowId)).toContain('workflow1'); + }); + }); + + it('calls orchestrator.get-workflow-schema and returns the input schema', async () => { + mockOrchestratorService.fetchWorkflowInfo.mockResolvedValue({ + id: 'workflow1', + serviceUrl: 'http://localhost:8080', + }); + mockOrchestratorService.fetchWorkflowDefinition.mockResolvedValue({ + id: 'workflow1', + dataInputSchema: 'schema.json', + }); + mockOrchestratorService.fetchWorkflowInfoOnService.mockResolvedValue({ + id: 'workflow1', + inputSchema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }); + + await withMcpClient(allowedBackend.server, async client => { + const result = await client.callTool( + { + name: 'orchestrator.get-workflow-schema', + arguments: { workflowId: 'workflow1' }, + }, + CallToolResultSchema, + ); + + const output = parseCallToolOutput(result) as { + inputSchema: { required: string[] }; + }; + + expect(result.isError).not.toBe(true); + expect(output.inputSchema.required).toEqual(['name']); + }); + }); + + it('returns a tool error when orchestrator.get-workflow-schema targets a missing workflow', async () => { + mockOrchestratorService.fetchWorkflowInfo.mockResolvedValue(undefined); + + await withMcpClient(allowedBackend.server, async client => { + const result = await client.callTool( + { + name: 'orchestrator.get-workflow-schema', + arguments: { workflowId: 'missing-workflow' }, + }, + CallToolResultSchema, + ); + + expect(result.isError).toBe(true); + expect(parseCallToolError(result)).toContain('missing-workflow'); + }); + }); + + it('calls orchestrator.execute-workflow and returns the new instance id and status', async () => { + mockOrchestratorService.fetchWorkflowInfo.mockResolvedValue({ + id: 'workflow1', + serviceUrl: 'http://localhost:8080', + }); + mockOrchestratorService.fetchWorkflowDefinition.mockResolvedValue({ + id: 'workflow1', + }); + mockOrchestratorService.executeWorkflow.mockResolvedValue({ + id: 'instance1', + }); + mockOrchestratorService.fetchInstance.mockResolvedValue(rawInstance()); + + await withMcpClient(allowedBackend.server, async client => { + const result = await client.callTool( + { + name: 'orchestrator.execute-workflow', + arguments: { workflowId: 'workflow1', inputs: {} }, + }, + CallToolResultSchema, + ); + + const output = parseCallToolOutput(result) as { + instanceId: string; + status: string; + }; + + expect(result.isError).not.toBe(true); + expect(output).toEqual({ instanceId: 'instance1', status: 'ACTIVE' }); + }); + }); + + it('calls orchestrator.list-instances and returns configured instances', async () => { + mockOrchestratorService.getWorkflowIds.mockReturnValue(['workflow1']); + mockOrchestratorService.fetchInstances.mockResolvedValue([rawInstance()]); + + await withMcpClient(allowedBackend.server, async client => { + const result = await client.callTool( + { name: 'orchestrator.list-instances', arguments: {} }, + CallToolResultSchema, + ); + + const output = parseCallToolOutput(result) as { + instances: Array<{ instanceId: string }>; + }; + + expect(result.isError).not.toBe(true); + expect(output.instances.map(i => i.instanceId)).toContain('instance1'); + }); + }); + + it('calls orchestrator.get-instance and returns the instance details', async () => { + mockOrchestratorService.fetchInstance.mockResolvedValue(rawInstance()); + + await withMcpClient(allowedBackend.server, async client => { + const result = await client.callTool( + { + name: 'orchestrator.get-instance', + arguments: { instanceId: 'instance1' }, + }, + CallToolResultSchema, + ); + + const output = parseCallToolOutput(result) as { instanceId: string }; + + expect(result.isError).not.toBe(true); + expect(output.instanceId).toBe('instance1'); + }); + }); + + it('returns a tool error when access is denied', async () => { + await withMcpClient(deniedBackend.server, async client => { + const result = await client.callTool( + { name: 'orchestrator.list-workflows', arguments: {} }, + CallToolResultSchema, + ); + + expect(result.isError).toBe(true); + expect(parseCallToolError(result)).toContain('denied'); + }); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/plugin.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/plugin.ts index e4979e8a5da..8d52bc962e8 100644 --- a/workspaces/orchestrator/plugins/orchestrator-backend/src/plugin.ts +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/plugin.ts @@ -18,6 +18,8 @@ import { coreServices, createBackendPlugin, } from '@backstage/backend-plugin-api'; +import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; +import { createConditionTransformer } from '@backstage/plugin-permission-node'; import { orchestratorPermissions } from '@red-hat-developer-hub/backstage-plugin-orchestrator-common'; import { @@ -25,6 +27,7 @@ import { workflowLogsExtensionEndpoint, } from '@red-hat-developer-hub/backstage-plugin-orchestrator-node'; +import { createOrchestratorActions } from './actions'; import { WorkflowLogsProvidersRegistry } from './providers/WorkflowLogsProvidersRegistry'; import { createRouter } from './routerWrapper'; import { initPublicServices } from './service/initPublicServices'; @@ -65,9 +68,16 @@ export const orchestratorPlugin = createBackendPlugin({ httpAuth: coreServices.httpAuth, http: coreServices.httpRouter, userInfo: coreServices.userInfo, + actionsRegistry: actionsRegistryServiceRef, }, async init(props) { - const { http, permissionsRegistry } = props; + const { + http, + permissionsRegistry, + actionsRegistry, + permissions, + userInfo, + } = props; const publicServices = initPublicServices( props.logger, @@ -87,6 +97,24 @@ export const orchestratorPlugin = createBackendPlugin({ rules: orchestratorPermissionRules, }); + // Constructed once and shared by both the HTTP router + // (`createRouter` below) and the MCP actions, mirroring + // `service/router.ts`'s own construction from the same ruleset. + const conditionTransformer = createConditionTransformer( + permissionsRegistry.getPermissionRuleset( + orchestratorWorkflowResourceRef, + ), + ); + + createOrchestratorActions({ + actionsRegistry, + permissions, + userInfo, + orchestratorService: publicServices.orchestratorService, + conditionTransformer, + logger: props.logger, + }); + const router = await createRouter({ ...props, workflowLogsProvidersRegistry, diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/service/resolveWorkflowDefinition.test.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/service/resolveWorkflowDefinition.test.ts new file mode 100644 index 00000000000..5de77bbab08 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/service/resolveWorkflowDefinition.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Unit tests for `resolveWorkflowDefinition`, extracted out of + * `actions/executeWorkflow.ts` and `actions/getWorkflowSchema.ts`, which + * both resolved a workflow's info/serviceUrl/definition (404-ing on any + * miss) in an identical, duplicated block. + */ + +import { NotFoundError } from '@backstage/errors'; + +import { OrchestratorService } from './OrchestratorService'; +import { resolveWorkflowDefinition } from './resolveWorkflowDefinition'; + +describe('resolveWorkflowDefinition', () => { + const mockOrchestratorService = { + fetchWorkflowInfo: jest.fn(), + fetchWorkflowDefinition: jest.fn(), + } as unknown as OrchestratorService; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('resolves the workflow info, service URL, and definition', async () => { + (mockOrchestratorService.fetchWorkflowInfo as jest.Mock).mockResolvedValue({ + id: 'workflow1', + serviceUrl: 'http://svc', + }); + ( + mockOrchestratorService.fetchWorkflowDefinition as jest.Mock + ).mockResolvedValue({ dataInputSchema: 'schema.json' }); + + const result = await resolveWorkflowDefinition( + mockOrchestratorService, + 'workflow1', + ); + + expect(result).toEqual({ + workflowInfo: { id: 'workflow1', serviceUrl: 'http://svc' }, + serviceUrl: 'http://svc', + definition: { dataInputSchema: 'schema.json' }, + }); + expect(mockOrchestratorService.fetchWorkflowInfo).toHaveBeenCalledWith({ + definitionId: 'workflow1', + }); + expect( + mockOrchestratorService.fetchWorkflowDefinition, + ).toHaveBeenCalledWith({ definitionId: 'workflow1' }); + }); + + it('throws NotFoundError when the workflow does not exist', async () => { + (mockOrchestratorService.fetchWorkflowInfo as jest.Mock).mockResolvedValue( + undefined, + ); + + await expect( + resolveWorkflowDefinition(mockOrchestratorService, 'missing'), + ).rejects.toThrow(NotFoundError); + expect( + mockOrchestratorService.fetchWorkflowDefinition, + ).not.toHaveBeenCalled(); + }); + + it('throws NotFoundError when the workflow has no service URL configured', async () => { + (mockOrchestratorService.fetchWorkflowInfo as jest.Mock).mockResolvedValue({ + id: 'workflow1', + }); + + await expect( + resolveWorkflowDefinition(mockOrchestratorService, 'workflow1'), + ).rejects.toThrow(NotFoundError); + expect( + mockOrchestratorService.fetchWorkflowDefinition, + ).not.toHaveBeenCalled(); + }); + + it('throws NotFoundError when the workflow definition cannot be fetched', async () => { + (mockOrchestratorService.fetchWorkflowInfo as jest.Mock).mockResolvedValue({ + id: 'workflow1', + serviceUrl: 'http://svc', + }); + ( + mockOrchestratorService.fetchWorkflowDefinition as jest.Mock + ).mockResolvedValue(undefined); + + await expect( + resolveWorkflowDefinition(mockOrchestratorService, 'workflow1'), + ).rejects.toThrow(NotFoundError); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/service/resolveWorkflowDefinition.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/service/resolveWorkflowDefinition.ts new file mode 100644 index 00000000000..2c5692bc725 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/service/resolveWorkflowDefinition.ts @@ -0,0 +1,65 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NotFoundError } from '@backstage/errors'; + +import { + WorkflowDefinition, + WorkflowInfo, +} from '@red-hat-developer-hub/backstage-plugin-orchestrator-common'; + +import { OrchestratorService } from './OrchestratorService'; + +/** + * Resolves a workflow's info, service URL, and definition, throwing + * `NotFoundError` on any missing piece. Shared by the `execute-workflow` + * and `get-workflow-schema` MCP actions, which both need this same + * "workflow exists, has a service URL, and has a fetchable definition" + * precondition before doing their own thing with it. + */ +export const resolveWorkflowDefinition = async ( + orchestratorService: OrchestratorService, + workflowId: string, +): Promise<{ + workflowInfo: WorkflowInfo; + serviceUrl: string; + definition: WorkflowDefinition; +}> => { + const workflowInfo = await orchestratorService.fetchWorkflowInfo({ + definitionId: workflowId, + }); + if (!workflowInfo) { + throw new NotFoundError(`Workflow "${workflowId}" not found`); + } + + const serviceUrl = workflowInfo.serviceUrl; + if (!serviceUrl) { + throw new NotFoundError( + `Workflow "${workflowId}" does not have a service URL configured`, + ); + } + + const definition = await orchestratorService.fetchWorkflowDefinition({ + definitionId: workflowId, + }); + if (!definition) { + throw new NotFoundError( + `Workflow definition for "${workflowId}" not found`, + ); + } + + return { workflowInfo, serviceUrl, definition }; +}; diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/service/router.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/service/router.ts index 4f6424d26ce..43954c331c1 100644 --- a/workspaces/orchestrator/plugins/orchestrator-backend/src/service/router.ts +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/service/router.ts @@ -23,9 +23,7 @@ import { PermissionsService, UserInfoService, } from '@backstage/backend-plugin-api'; -import { NotAllowedError } from '@backstage/errors'; import { - AuthorizeResult, BasicPermission, PolicyDecision, ResourcePermission, @@ -43,15 +41,10 @@ import { Request as HttpRequest } from 'express-serve-static-core'; import { OpenAPIBackend, Request } from 'openapi-backend'; import { - FieldFilter, Filter, - NestedFilter, openApiDocument, - orchestratorInstanceAdminViewPermission, orchestratorWorkflowPermission, - orchestratorWorkflowSpecificPermission, // @deprecated Remove in next release orchestratorWorkflowUsePermission, - orchestratorWorkflowUseSpecificPermission, // @deprecated Remove in next release WorkflowOverviewListResultDTO, } from '@red-hat-developer-hub/backstage-plugin-orchestrator-common'; @@ -63,37 +56,18 @@ import { OrchestratorService } from './OrchestratorService'; import { OrchestratorFilters, orchestratorWorkflowResourceRef, - WorkflowIdParam, } from './permission-rules'; +import * as workflowAuth from './workflowAuthorization'; interface RouterApi { openApiBackend: OpenAPIBackend; v2: V2; } -const matches = ( - workflow: WorkflowIdParam, - filters?: OrchestratorFilters, -): boolean => { - if (!filters) { - return true; - } - - if ('allOf' in filters) { - return filters.allOf.every(filter => matches(workflow, filter)); - } - - if ('anyOf' in filters) { - return filters.anyOf.some(filter => matches(workflow, filter)); - } - - if ('not' in filters) { - return !matches(workflow, filters.not); - } - - return filters.values.includes(workflow.workflowId); -}; - +// Thin, request-based adapters over the shared, credentials-based helpers in +// `workflowAuthorization.ts`. Kept here (rather than inlined at every call +// site below) so the ~10 `setupInternalRoutes` handlers don't need to change +// at all — they keep resolving credentials implicitly via `httpAuth`. const authorize = async ( request: HttpRequest, genericPermission: @@ -102,78 +76,7 @@ const authorize = async ( httpAuth: HttpAuthService, ): Promise => { const credentials = await httpAuth.credentials(request); - - if (genericPermission.type === 'resource') { - const decisions = await permissionsSvc.authorizeConditional( - [{ permission: genericPermission }], - { credentials }, - ); - return decisions[0]; - } - const decision = ( - await permissionsSvc.authorize([{ permission: genericPermission }], { - credentials, - }) - )[0]; - return decision; -}; - -// @deprecated Remove in next release — legacy dynamic permission fallback -const legacyAuthorize = async ( - request: HttpRequest, - specificPermission: BasicPermission, - permissionsSvc: PermissionsService, - httpAuth: HttpAuthService, - logger: LoggerService, -): Promise => { - const credentials = await httpAuth.credentials(request); - const [decision] = await permissionsSvc.authorize( - [{ permission: specificPermission }], - { credentials }, - ); - if (decision.result === AuthorizeResult.ALLOW) { - logger.warn( - `Dynamic permission "${specificPermission.name}" granted access. ` + - `This permission is deprecated. Migrate to conditional policies with IS_ALLOWED_WORKFLOW_ID rule.`, - ); - return true; - } - return false; -}; - -// @deprecated Remove in next release — batched legacy fallback for list filtering -const legacyAuthorizeBatch = async ( - credentials: Awaited>, - workflowIds: string[], - specificPermissionFactory: (workflowId: string) => BasicPermission, - permissionsSvc: PermissionsService, - logger: LoggerService, -): Promise => { - if (workflowIds.length === 0) { - return []; - } - - const specificWorkflowRequests = workflowIds.map(workflowId => ({ - permission: specificPermissionFactory(workflowId), - })); - - const decisions = await permissionsSvc.authorize(specificWorkflowRequests, { - credentials, - }); - - const legacyAllowed: string[] = []; - workflowIds.forEach((workflowId, idx) => { - if (decisions[idx]?.result === AuthorizeResult.ALLOW) { - const permission = specificPermissionFactory(workflowId); - logger.warn( - `Dynamic permission "${permission.name}" granted access. ` + - `This permission is deprecated. Migrate to conditional policies with IS_ALLOWED_WORKFLOW_ID rule.`, - ); - legacyAllowed.push(workflowId); - } - }); - - return legacyAllowed; + return workflowAuth.authorize(credentials, genericPermission, permissionsSvc); }; const isUserAuthorizedForInstanceAdminViewPermission = async ( @@ -182,12 +85,10 @@ const isUserAuthorizedForInstanceAdminViewPermission = async ( httpAuth: HttpAuthService, ): Promise => { const credentials = await httpAuth.credentials(request); - const [decision] = await permissionsSvc.authorize( - [{ permission: orchestratorInstanceAdminViewPermission }], - { credentials }, + return workflowAuth.isUserAuthorizedForInstanceAdminViewPermission( + credentials, + permissionsSvc, ); - - return decision.result === AuthorizeResult.ALLOW; }; const filterAuthorizedWorkflowIds = async ( @@ -199,39 +100,13 @@ const filterAuthorizedWorkflowIds = async ( logger: LoggerService, ): Promise => { const credentials = await httpAuth.credentials(request); - const [genericDecision] = await permissionsSvc.authorizeConditional( - [{ permission: orchestratorWorkflowPermission }], - { credentials }, + return workflowAuth.filterAuthorizedWorkflowIds( + credentials, + permissionsSvc, + workflowIds, + conditionTransformer, + logger, ); - - if (genericDecision.result === AuthorizeResult.ALLOW) { - return workflowIds; - } - - let conditionallyAllowed: string[] = []; - let remainingIds: string[] = workflowIds; - - if (genericDecision.result === AuthorizeResult.CONDITIONAL) { - const filters = conditionTransformer(genericDecision.conditions); - conditionallyAllowed = workflowIds.filter(id => - matches({ workflowId: id }, filters), - ); - remainingIds = workflowIds.filter(id => !conditionallyAllowed.includes(id)); - } - - // @deprecated Remove this legacy fallback block in next release - if (remainingIds.length > 0) { - const legacyAllowed = await legacyAuthorizeBatch( - credentials, - remainingIds, - orchestratorWorkflowSpecificPermission, - permissionsSvc, - logger, - ); - return [...conditionallyAllowed, ...legacyAllowed]; - } - - return conditionallyAllowed; }; const filterAuthorizedWorkflows = async ( @@ -242,27 +117,14 @@ const filterAuthorizedWorkflows = async ( conditionTransformer: ConditionTransformer, logger: LoggerService, ): Promise => { - if (!workflows.overviews) { - return workflows; - } - - const authorizedWorkflowIds = await filterAuthorizedWorkflowIds( - request, + const credentials = await httpAuth.credentials(request); + return workflowAuth.filterAuthorizedWorkflows( + credentials, permissionsSvc, - httpAuth, - workflows.overviews.map(w => w.workflowId), + workflows, conditionTransformer, logger, ); - - const filtered = { - ...workflows, - overviews: workflows.overviews.filter(w => - authorizedWorkflowIds.includes(w.workflowId), - ), - }; - - return filtered; }; export async function createBackendRouter( @@ -418,36 +280,20 @@ function setupInternalRoutes( genericPermission: ResourcePermission<'orchestrator-workflow'>, auditEvent: AuditorServiceEvent, ): Promise { - if (decision.result === AuthorizeResult.ALLOW) { - return; - } - - if (decision.result === AuthorizeResult.CONDITIONAL) { - const filters = conditionTransformer(decision.conditions); - if (matches({ workflowId }, filters)) { - return; - } - } - - // @deprecated Remove this legacy fallback block in next release - const specificPermission = - genericPermission === orchestratorWorkflowPermission - ? orchestratorWorkflowSpecificPermission(workflowId) - : orchestratorWorkflowUseSpecificPermission(workflowId); - - const legacyAllowed = await legacyAuthorize( - request, - specificPermission, + const credentials = await httpAuth.credentials(request); + const allowed = await workflowAuth.isWorkflowAccessAllowed( + credentials, + decision, + workflowId, + genericPermission, + conditionTransformer, permissions, - httpAuth, logger, ); - if (legacyAllowed) { - return; + if (!allowed) { + manageDenyAuthorization(auditEvent); } - - manageDenyAuthorization(auditEvent); } // v2 @@ -975,30 +821,12 @@ function setupInternalRoutes( const requestFilters = getRequestFilters(req); - let filters = requestFilters; - - if (!isUserAuthorizedForInstanceAdminView) { - const initiatorEntityFilter: FieldFilter = { - operator: 'EQ', - value: initiatorEntity, - field: 'initiatorEntity', - }; - - const nestedVariablesFilter: NestedFilter = { - field: 'variables', - nested: initiatorEntityFilter, - }; - - if (requestFilters === undefined) { - filters = nestedVariablesFilter; - } else { - // combine filters - filters = { - operator: 'AND', - filters: [nestedVariablesFilter, requestFilters], - }; - } - } + const filters = isUserAuthorizedForInstanceAdminView + ? requestFilters + : workflowAuth.buildInstanceOwnershipFilter( + initiatorEntity, + requestFilters, + ); const result = await routerApi.v2.getInstances( buildPagination(req), @@ -1050,45 +878,13 @@ function setupInternalRoutes( ); const credentials = await httpAuth.credentials(request); - const initiatorEntity = (await userInfo.getUserInfo(credentials)) - .userEntityRef; - // Check if user is authorized to view all instances - const isUserAuthorizedForInstanceAdminView = - await isUserAuthorizedForInstanceAdminViewPermission( - request, - permissions, - httpAuth, - ); - - // If not an admin, enforce initiatorEntity check - if (!isUserAuthorizedForInstanceAdminView) { - const instanceInitiatorEntity = instance.initiatorEntity; - - // If the instance has no initiatorEntity recorded, we cannot determine ownership. - // This can happen for: - // 1. Workflow instances created before the initiatorEntity feature was added - // 2. Workflow instances started externally (not through Backstage) - // 3. Workflows that transform/overwrite their input variables - if (!instanceInitiatorEntity) { - throw new NotAllowedError( - `Access denied for instance ${instanceId}. ` + - `You have permission to view workflow '${workflowId}', but this workflow run ` + - `does not have ownership information recorded. Since we cannot verify you ` + - `initiated this run, the 'orchestrator.instanceAdminView' permission is required. ` + - `Contact your administrator to grant this permission.`, - ); - } - - if (instanceInitiatorEntity !== initiatorEntity) { - throw new NotAllowedError( - `Access denied for instance ${instanceId}. ` + - `This workflow run was initiated by '${instanceInitiatorEntity}', not by you ('${initiatorEntity}'). ` + - `With 'orchestrator.workflow' or 'orchestrator.workflow.${workflowId}' permissions, ` + - `you can only view instances you created. To view all instances, you need the ` + - `'orchestrator.instanceAdminView' permission.`, - ); - } - } + await workflowAuth.assertInstanceOwnership( + credentials, + permissions, + userInfo, + instance, + instanceId, + ); auditEvent.success(); res.status(200).json(instance); diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/service/workflowAuthorization.test.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/service/workflowAuthorization.test.ts new file mode 100644 index 00000000000..8c645fa0d0a --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/service/workflowAuthorization.test.ts @@ -0,0 +1,720 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Unit tests for the credentials-based authorization helpers shared by + * `service/router.ts` and `actions/*.ts`. + * + * These tests double as the behavior-preservation safety net for extracting + * this logic out of `router.ts` (which remains covered end-to-end by + * `router.test.ts`): every branch here mirrors a scenario already exercised + * there (generic ALLOW/DENY, conditional rule matching, the deprecated + * per-workflow fallback, and the instance-ownership/admin-view checks). + */ + +import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; +import { NotAllowedError } from '@backstage/errors'; +import { + AuthorizeResult, + type PolicyDecision, +} from '@backstage/plugin-permission-common'; +import { createConditionTransformer } from '@backstage/plugin-permission-node'; + +import { + ORCHESTRATOR_WORKFLOW_RESOURCE_TYPE, + orchestratorWorkflowPermission, + orchestratorWorkflowUsePermission, +} from '@red-hat-developer-hub/backstage-plugin-orchestrator-common'; + +import { + OrchestratorFilters, + orchestratorPermissionRules, +} from './permission-rules'; +import { + assertAnyWorkflowAccess, + assertInstanceOwnership, + assertWorkflowAccess, + authorize, + authorizeWorkflowAccess, + buildInstanceOwnershipFilter, + filterAuthorizedWorkflowIds, + filterAuthorizedWorkflows, + isUserAuthorizedForInstanceAdminViewPermission, + isWorkflowAccessAllowed, + matchesWorkflowId, +} from './workflowAuthorization'; + +const credentials = mockCredentials.user('user:default/test-user'); + +// Same `permissionsRegistry.getPermissionRuleset(orchestratorWorkflowResourceRef)` +// shape used in production (see `plugin.ts`/`router.ts`) and in +// `router.test.ts`'s own mock. +const conditionTransformer = createConditionTransformer({ + getRuleByName: (name: string) => { + const rule = orchestratorPermissionRules.find(r => r.name === name); + if (!rule) { + throw new Error(`Unknown rule: ${name}`); + } + return rule; + }, +}); + +function conditionalDecision(workflowIds: string[]): PolicyDecision { + return { + result: AuthorizeResult.CONDITIONAL, + pluginId: 'orchestrator', + resourceType: ORCHESTRATOR_WORKFLOW_RESOURCE_TYPE, + conditions: { + anyOf: [ + { + rule: 'IS_ALLOWED_WORKFLOW_ID', + resourceType: ORCHESTRATOR_WORKFLOW_RESOURCE_TYPE, + params: { workflowIds }, + }, + ], + }, + } as PolicyDecision; +} + +describe('matchesWorkflowId', () => { + it('returns true when there are no filters', () => { + expect(matchesWorkflowId({ workflowId: 'workflow1' }, undefined)).toBe( + true, + ); + }); + + it('matches a leaf filter by workflow id', () => { + const filters = { key: 'workflowIds', values: ['workflow1', 'workflow2'] }; + expect(matchesWorkflowId({ workflowId: 'workflow1' }, filters)).toBe(true); + expect(matchesWorkflowId({ workflowId: 'workflow3' }, filters)).toBe(false); + }); + + it('evaluates allOf/anyOf/not compositions', () => { + const allowed = { key: 'workflowIds', values: ['workflow1'] }; + const denied = { key: 'workflowIds', values: ['workflow2'] }; + + expect( + matchesWorkflowId({ workflowId: 'workflow1' }, { allOf: [allowed] }), + ).toBe(true); + expect( + matchesWorkflowId( + { workflowId: 'workflow1' }, + { allOf: [allowed, denied] }, + ), + ).toBe(false); + expect( + matchesWorkflowId( + { workflowId: 'workflow1' }, + { anyOf: [denied, allowed] }, + ), + ).toBe(true); + expect( + matchesWorkflowId({ workflowId: 'workflow1' }, { not: denied }), + ).toBe(true); + }); +}); + +describe('authorize', () => { + it('calls authorizeConditional for a resource-scoped permission', async () => { + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + + const decision = await authorize( + credentials, + orchestratorWorkflowPermission, + mockPermissions, + ); + + expect(decision.result).toBe(AuthorizeResult.ALLOW); + expect(mockPermissions.authorizeConditional).toHaveBeenCalledWith( + [{ permission: orchestratorWorkflowPermission }], + { credentials }, + ); + expect(mockPermissions.authorize).not.toHaveBeenCalled(); + }); + + it('calls authorize for a basic (non-resource) permission', async () => { + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + const basicPermission = { + type: 'basic' as const, + name: 'orchestrator.instanceAdminView', + attributes: {}, + }; + + const decision = await authorize( + credentials, + basicPermission, + mockPermissions, + ); + + expect(decision.result).toBe(AuthorizeResult.DENY); + expect(mockPermissions.authorize).toHaveBeenCalledWith( + [{ permission: basicPermission }], + { credentials }, + ); + }); +}); + +describe('isWorkflowAccessAllowed / assertWorkflowAccess', () => { + it('allows access on a generic ALLOW decision', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + + const allowed = await isWorkflowAccessAllowed( + credentials, + { result: AuthorizeResult.ALLOW }, + 'workflow1', + orchestratorWorkflowPermission, + conditionTransformer, + mockPermissions, + logger, + ); + + expect(allowed).toBe(true); + expect(mockPermissions.authorize).not.toHaveBeenCalled(); + }); + + it('allows access when the conditional rule matches the workflow id', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + + const allowed = await isWorkflowAccessAllowed( + credentials, + conditionalDecision(['workflow1']), + 'workflow1', + orchestratorWorkflowPermission, + conditionTransformer, + mockPermissions, + logger, + ); + + expect(allowed).toBe(true); + expect(mockPermissions.authorize).not.toHaveBeenCalled(); + }); + + it('denies access when the conditional rule does not match and no legacy fallback exists', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + const allowed = await isWorkflowAccessAllowed( + credentials, + conditionalDecision(['workflow2']), + 'workflow1', + orchestratorWorkflowPermission, + conditionTransformer, + mockPermissions, + logger, + ); + + expect(allowed).toBe(false); + }); + + // @deprecated scenario — remove once orchestratorWorkflowSpecificPermission is removed + it('falls back to the deprecated per-workflow permission on a generic DENY', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + + const allowed = await isWorkflowAccessAllowed( + credentials, + { result: AuthorizeResult.DENY }, + 'workflow1', + orchestratorWorkflowPermission, + conditionTransformer, + mockPermissions, + logger, + ); + + expect(allowed).toBe(true); + expect(mockPermissions.authorize).toHaveBeenCalledWith( + [ + { + permission: expect.objectContaining({ + name: 'orchestrator.workflow.workflow1', + }), + }, + ], + { credentials }, + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('deprecated'), + ); + }); + + // @deprecated scenario — remove once orchestratorWorkflowUseSpecificPermission is removed + it('uses the "use" specific permission fallback for orchestratorWorkflowUsePermission', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + const allowed = await isWorkflowAccessAllowed( + credentials, + { result: AuthorizeResult.DENY }, + 'workflow1', + orchestratorWorkflowUsePermission, + conditionTransformer, + mockPermissions, + logger, + ); + + expect(allowed).toBe(false); + expect(mockPermissions.authorize).toHaveBeenCalledWith( + [ + { + permission: expect.objectContaining({ + name: 'orchestrator.workflow.use.workflow1', + }), + }, + ], + { credentials }, + ); + }); + + it('assertWorkflowAccess resolves silently when access is allowed', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + + await expect( + assertWorkflowAccess( + credentials, + { result: AuthorizeResult.ALLOW }, + 'workflow1', + orchestratorWorkflowPermission, + conditionTransformer, + mockPermissions, + logger, + ), + ).resolves.toBeUndefined(); + }); + + it('assertWorkflowAccess throws NotAllowedError when access is denied', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + await expect( + assertWorkflowAccess( + credentials, + { result: AuthorizeResult.DENY }, + 'workflow1', + orchestratorWorkflowPermission, + conditionTransformer, + mockPermissions, + logger, + ), + ).rejects.toThrow(NotAllowedError); + }); +}); + +describe('authorizeWorkflowAccess', () => { + it('resolves silently when the generic permission allows the workflow', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + + await expect( + authorizeWorkflowAccess( + credentials, + 'workflow1', + orchestratorWorkflowPermission, + conditionTransformer, + mockPermissions, + logger, + ), + ).resolves.toBeUndefined(); + }); + + it('resolves silently when a conditional decision matches the workflow id', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + conditionalDecision(['workflow1']), + ]); + + await expect( + authorizeWorkflowAccess( + credentials, + 'workflow1', + orchestratorWorkflowPermission, + conditionTransformer, + mockPermissions, + logger, + ), + ).resolves.toBeUndefined(); + }); + + it('throws NotAllowedError when the workflow is not covered by the decision', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + conditionalDecision(['workflow2']), + ]); + // @deprecated legacy fallback also denies + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + await expect( + authorizeWorkflowAccess( + credentials, + 'workflow1', + orchestratorWorkflowPermission, + conditionTransformer, + mockPermissions, + logger, + ), + ).rejects.toThrow(NotAllowedError); + }); +}); + +describe('assertAnyWorkflowAccess', () => { + it('resolves silently on a generic ALLOW', async () => { + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + + await expect( + assertAnyWorkflowAccess( + credentials, + mockPermissions, + orchestratorWorkflowPermission, + ), + ).resolves.toBeUndefined(); + }); + + it('resolves silently on a CONDITIONAL decision', async () => { + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + conditionalDecision(['workflow1']), + ]); + + await expect( + assertAnyWorkflowAccess( + credentials, + mockPermissions, + orchestratorWorkflowPermission, + ), + ).resolves.toBeUndefined(); + }); + + it('throws NotAllowedError on a generic DENY', async () => { + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + await expect( + assertAnyWorkflowAccess( + credentials, + mockPermissions, + orchestratorWorkflowPermission, + ), + ).rejects.toThrow(NotAllowedError); + }); +}); + +describe('filterAuthorizedWorkflowIds', () => { + it('returns all workflow ids on a generic ALLOW', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + + const ids = await filterAuthorizedWorkflowIds( + credentials, + mockPermissions, + ['workflow1', 'workflow2'], + conditionTransformer, + logger, + ); + + expect(ids).toEqual(['workflow1', 'workflow2']); + expect(mockPermissions.authorize).not.toHaveBeenCalled(); + }); + + it('combines conditionally-matched ids with the legacy fallback for the rest', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + conditionalDecision(['workflow1']), + ]); + // @deprecated legacy fallback for the unmatched workflow2 + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + + const ids = await filterAuthorizedWorkflowIds( + credentials, + mockPermissions, + ['workflow1', 'workflow2'], + conditionTransformer, + logger, + ); + + expect(ids.sort()).toEqual(['workflow1', 'workflow2']); + }); + + it('returns an empty array when access is denied with no legacy fallback', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + const ids = await filterAuthorizedWorkflowIds( + credentials, + mockPermissions, + ['workflow1', 'workflow2'], + conditionTransformer, + logger, + ); + + expect(ids).toEqual([]); + }); + + it('returns an empty array for an empty input list, without invoking the legacy fallback', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + const ids = await filterAuthorizedWorkflowIds( + credentials, + mockPermissions, + [], + conditionTransformer, + logger, + ); + + expect(ids).toEqual([]); + // @deprecated legacy fallback batch is skipped entirely for an empty list + expect(mockPermissions.authorize).not.toHaveBeenCalled(); + }); +}); + +describe('filterAuthorizedWorkflows', () => { + it('filters overviews down to the authorized workflow ids', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + mockPermissions.authorizeConditional.mockResolvedValue([ + conditionalDecision(['workflow1']), + ]); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + const result = await filterAuthorizedWorkflows( + credentials, + mockPermissions, + { + overviews: [ + { workflowId: 'workflow1', format: 'yaml' }, + { workflowId: 'workflow2', format: 'yaml' }, + ], + }, + conditionTransformer, + logger, + ); + + expect(result.overviews?.map(w => w.workflowId)).toEqual(['workflow1']); + }); + + it('passes through unchanged when there are no overviews', async () => { + const mockPermissions = mockServices.permissions.mock(); + const logger = mockServices.logger.mock(); + + const workflows = { paginationInfo: { pageSize: 10, offset: 0 } }; + const result = await filterAuthorizedWorkflows( + credentials, + mockPermissions, + workflows, + conditionTransformer, + logger, + ); + + expect(result).toBe(workflows); + expect(mockPermissions.authorizeConditional).not.toHaveBeenCalled(); + }); +}); + +describe('isUserAuthorizedForInstanceAdminViewPermission', () => { + it('returns true when the admin-view permission is granted', async () => { + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + + await expect( + isUserAuthorizedForInstanceAdminViewPermission( + credentials, + mockPermissions, + ), + ).resolves.toBe(true); + }); + + it('returns false when the admin-view permission is denied', async () => { + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + await expect( + isUserAuthorizedForInstanceAdminViewPermission( + credentials, + mockPermissions, + ), + ).resolves.toBe(false); + }); +}); + +describe('assertInstanceOwnership', () => { + const mockUserInfo = mockServices.userInfo.mock(); + + beforeEach(() => { + mockUserInfo.getUserInfo.mockResolvedValue({ + userEntityRef: 'user:default/test-user', + ownershipEntityRefs: [], + }); + }); + + it('allows access when the caller holds the instanceAdminView permission', async () => { + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.ALLOW }, + ]); + + await expect( + assertInstanceOwnership( + credentials, + mockPermissions, + mockUserInfo, + { initiatorEntity: 'user:default/someone-else' }, + 'instance-1', + ), + ).resolves.toBeUndefined(); + }); + + it('allows access when the caller initiated the instance', async () => { + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + await expect( + assertInstanceOwnership( + credentials, + mockPermissions, + mockUserInfo, + { initiatorEntity: 'user:default/test-user' }, + 'instance-1', + ), + ).resolves.toBeUndefined(); + }); + + it('throws NotAllowedError when the instance has no recorded initiator', async () => { + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + await expect( + assertInstanceOwnership( + credentials, + mockPermissions, + mockUserInfo, + {}, + 'instance-1', + ), + ).rejects.toThrow(NotAllowedError); + }); + + it('throws NotAllowedError when the instance was initiated by someone else', async () => { + const mockPermissions = mockServices.permissions.mock(); + mockPermissions.authorize.mockResolvedValue([ + { result: AuthorizeResult.DENY }, + ]); + + await expect( + assertInstanceOwnership( + credentials, + mockPermissions, + mockUserInfo, + { initiatorEntity: 'user:default/someone-else' }, + 'instance-1', + ), + ).rejects.toThrow(NotAllowedError); + }); +}); + +describe('buildInstanceOwnershipFilter', () => { + it('builds a nested initiatorEntity filter when there is no existing filter', () => { + expect(buildInstanceOwnershipFilter('user:default/test-user')).toEqual({ + field: 'variables', + nested: { + operator: 'EQ', + value: 'user:default/test-user', + field: 'initiatorEntity', + }, + }); + }); + + it('combines with an existing filter using AND', () => { + const existingFilter = { + field: 'processId', + operator: 'EQ' as const, + value: 'workflow1', + }; + + expect( + buildInstanceOwnershipFilter('user:default/test-user', existingFilter), + ).toEqual({ + operator: 'AND', + filters: [ + { + field: 'variables', + nested: { + operator: 'EQ', + value: 'user:default/test-user', + field: 'initiatorEntity', + }, + }, + existingFilter, + ], + }); + }); +}); diff --git a/workspaces/orchestrator/plugins/orchestrator-backend/src/service/workflowAuthorization.ts b/workspaces/orchestrator/plugins/orchestrator-backend/src/service/workflowAuthorization.ts new file mode 100644 index 00000000000..a9735611d12 --- /dev/null +++ b/workspaces/orchestrator/plugins/orchestrator-backend/src/service/workflowAuthorization.ts @@ -0,0 +1,463 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + BackstageCredentials, + LoggerService, + PermissionsService, + UserInfoService, +} from '@backstage/backend-plugin-api'; +import { NotAllowedError } from '@backstage/errors'; +import { + AuthorizeResult, + BasicPermission, + PolicyDecision, + ResourcePermission, +} from '@backstage/plugin-permission-common'; +import { ConditionTransformer } from '@backstage/plugin-permission-node'; + +import { + FieldFilter, + Filter, + NestedFilter, + orchestratorInstanceAdminViewPermission, + orchestratorWorkflowPermission, + orchestratorWorkflowSpecificPermission, // @deprecated Remove in next release + orchestratorWorkflowUseSpecificPermission, // @deprecated Remove in next release + WorkflowOverviewListResultDTO, +} from '@red-hat-developer-hub/backstage-plugin-orchestrator-common'; + +import { OrchestratorFilters, WorkflowIdParam } from './permission-rules'; + +/** + * Credentials-based, shared authorization helpers used by both the HTTP + * router (`service/router.ts`) and the Orchestrator MCP actions + * (`actions/*.ts`). `router.ts` resolves `credentials` from the incoming + * `HttpRequest` once per handler and otherwise defers to these functions + * unchanged, so this module is the single source of truth for Orchestrator's + * authorization rules. + */ + +/** + * Evaluates a transformed permission-condition tree against a single + * workflow. Mirrors the shape produced by `ConditionTransformer` + * (see `permission-rules.ts`'s `isWorkflowId` rule's `toQuery`), i.e. the + * *post-transform* filter tree, not the raw `PermissionCondition` tree. + */ +export const matchesWorkflowId = ( + workflow: WorkflowIdParam, + filters?: OrchestratorFilters, +): boolean => { + if (!filters) { + return true; + } + + if ('allOf' in filters) { + return filters.allOf.every(filter => matchesWorkflowId(workflow, filter)); + } + + if ('anyOf' in filters) { + return filters.anyOf.some(filter => matchesWorkflowId(workflow, filter)); + } + + if ('not' in filters) { + return !matchesWorkflowId(workflow, filters.not); + } + + return filters.values.includes(workflow.workflowId); +}; + +/** + * Authorizes a (possibly resource-scoped) orchestrator permission for the + * given credentials. + */ +export const authorize = async ( + credentials: BackstageCredentials, + genericPermission: + ResourcePermission<'orchestrator-workflow'> | BasicPermission, + permissionsSvc: PermissionsService, +): Promise => { + if (genericPermission.type === 'resource') { + const decisions = await permissionsSvc.authorizeConditional( + [{ permission: genericPermission }], + { credentials }, + ); + return decisions[0]; + } + const decision = ( + await permissionsSvc.authorize([{ permission: genericPermission }], { + credentials, + }) + )[0]; + return decision; +}; + +// @deprecated Remove in next release — legacy dynamic permission fallback +const legacyAuthorize = async ( + credentials: BackstageCredentials, + specificPermission: BasicPermission, + permissionsSvc: PermissionsService, + logger: LoggerService, +): Promise => { + const [decision] = await permissionsSvc.authorize( + [{ permission: specificPermission }], + { credentials }, + ); + if (decision.result === AuthorizeResult.ALLOW) { + logger.warn( + `Dynamic permission "${specificPermission.name}" granted access. ` + + `This permission is deprecated. Migrate to conditional policies with IS_ALLOWED_WORKFLOW_ID rule.`, + ); + return true; + } + return false; +}; + +// @deprecated Remove in next release — batched legacy fallback for list filtering +const legacyAuthorizeBatch = async ( + credentials: BackstageCredentials, + workflowIds: string[], + specificPermissionFactory: (workflowId: string) => BasicPermission, + permissionsSvc: PermissionsService, + logger: LoggerService, +): Promise => { + if (workflowIds.length === 0) { + return []; + } + + const specificWorkflowRequests = workflowIds.map(workflowId => ({ + permission: specificPermissionFactory(workflowId), + })); + + const decisions = await permissionsSvc.authorize(specificWorkflowRequests, { + credentials, + }); + + const legacyAllowed: string[] = []; + workflowIds.forEach((workflowId, idx) => { + if (decisions[idx]?.result === AuthorizeResult.ALLOW) { + const permission = specificPermissionFactory(workflowId); + logger.warn( + `Dynamic permission "${permission.name}" granted access. ` + + `This permission is deprecated. Migrate to conditional policies with IS_ALLOWED_WORKFLOW_ID rule.`, + ); + legacyAllowed.push(workflowId); + } + }); + + return legacyAllowed; +}; + +/** + * Whether the caller holds `orchestrator.instanceAdminView`, which grants + * visibility into instances initiated by other entities. + */ +export const isUserAuthorizedForInstanceAdminViewPermission = async ( + credentials: BackstageCredentials, + permissionsSvc: PermissionsService, +): Promise => { + const [decision] = await permissionsSvc.authorize( + [{ permission: orchestratorInstanceAdminViewPermission }], + { credentials }, + ); + + return decision.result === AuthorizeResult.ALLOW; +}; + +/** + * Filters `workflowIds` down to the ones the caller is authorized to see + * under `orchestratorWorkflowPermission`, combining conditional rule + * matches with the deprecated per-workflow dynamic permission fallback. + */ +export const filterAuthorizedWorkflowIds = async ( + credentials: BackstageCredentials, + permissionsSvc: PermissionsService, + workflowIds: string[], + conditionTransformer: ConditionTransformer, + logger: LoggerService, +): Promise => { + const [genericDecision] = await permissionsSvc.authorizeConditional( + [{ permission: orchestratorWorkflowPermission }], + { credentials }, + ); + + if (genericDecision.result === AuthorizeResult.ALLOW) { + return workflowIds; + } + + let conditionallyAllowed: string[] = []; + let remainingIds: string[] = workflowIds; + + if (genericDecision.result === AuthorizeResult.CONDITIONAL) { + const filters = conditionTransformer(genericDecision.conditions); + conditionallyAllowed = workflowIds.filter(id => + matchesWorkflowId({ workflowId: id }, filters), + ); + remainingIds = workflowIds.filter(id => !conditionallyAllowed.includes(id)); + } + + // @deprecated Remove this legacy fallback block in next release + if (remainingIds.length > 0) { + const legacyAllowed = await legacyAuthorizeBatch( + credentials, + remainingIds, + orchestratorWorkflowSpecificPermission, + permissionsSvc, + logger, + ); + return [...conditionallyAllowed, ...legacyAllowed]; + } + + return conditionallyAllowed; +}; + +/** + * Filters a `WorkflowOverviewListResultDTO`'s `overviews` down to the + * workflows the caller is authorized to see. + */ +export const filterAuthorizedWorkflows = async ( + credentials: BackstageCredentials, + permissionsSvc: PermissionsService, + workflows: WorkflowOverviewListResultDTO, + conditionTransformer: ConditionTransformer, + logger: LoggerService, +): Promise => { + if (!workflows.overviews) { + return workflows; + } + + const authorizedWorkflowIds = await filterAuthorizedWorkflowIds( + credentials, + permissionsSvc, + workflows.overviews.map(w => w.workflowId), + conditionTransformer, + logger, + ); + + return { + ...workflows, + overviews: workflows.overviews.filter(w => + authorizedWorkflowIds.includes(w.workflowId), + ), + }; +}; + +/** + * Determines whether `workflowId` is allowed under `genericPermission`, + * given an already-resolved `decision` for that permission. Applies the + * conditional-rule match first, then falls back to the deprecated + * per-workflow dynamic permission during the migration window. + */ +export const isWorkflowAccessAllowed = async ( + credentials: BackstageCredentials, + decision: PolicyDecision, + workflowId: string, + genericPermission: ResourcePermission<'orchestrator-workflow'>, + conditionTransformer: ConditionTransformer, + permissionsSvc: PermissionsService, + logger: LoggerService, +): Promise => { + if (decision.result === AuthorizeResult.ALLOW) { + return true; + } + + if (decision.result === AuthorizeResult.CONDITIONAL) { + const filters = conditionTransformer(decision.conditions); + if (matchesWorkflowId({ workflowId }, filters)) { + return true; + } + } + + // @deprecated Remove this legacy fallback block in next release + const specificPermission = + genericPermission === orchestratorWorkflowPermission + ? orchestratorWorkflowSpecificPermission(workflowId) + : orchestratorWorkflowUseSpecificPermission(workflowId); + + return legacyAuthorize( + credentials, + specificPermission, + permissionsSvc, + logger, + ); +}; + +/** + * Asserts that `workflowId` is allowed under `genericPermission`, throwing + * `NotAllowedError` on deny. Used directly by MCP actions. `router.ts` keeps + * its own thin wrapper around `isWorkflowAccessAllowed` so it can preserve + * its existing audit-event integration and `UnauthorizedError` type on deny. + */ +export const assertWorkflowAccess = async ( + credentials: BackstageCredentials, + decision: PolicyDecision, + workflowId: string, + genericPermission: ResourcePermission<'orchestrator-workflow'>, + conditionTransformer: ConditionTransformer, + permissionsSvc: PermissionsService, + logger: LoggerService, +): Promise => { + const allowed = await isWorkflowAccessAllowed( + credentials, + decision, + workflowId, + genericPermission, + conditionTransformer, + permissionsSvc, + logger, + ); + + if (!allowed) { + throw new NotAllowedError( + `Access to workflow "${workflowId}" denied by permission "${genericPermission.name}"`, + ); + } +}; + +/** + * Combines `authorize` + `assertWorkflowAccess`: authorizes `genericPermission` + * for the caller, then asserts that `workflowId` specifically is covered by + * the resulting decision, throwing `NotAllowedError` on deny. Used by MCP + * actions that operate on a single, already-known workflow id (`get-instance`, + * `get-workflow-schema`, `execute-workflow`), where the intermediate + * `PolicyDecision` has no other use. + */ +export const authorizeWorkflowAccess = async ( + credentials: BackstageCredentials, + workflowId: string, + genericPermission: ResourcePermission<'orchestrator-workflow'>, + conditionTransformer: ConditionTransformer, + permissionsSvc: PermissionsService, + logger: LoggerService, +): Promise => { + const decision = await authorize( + credentials, + genericPermission, + permissionsSvc, + ); + await assertWorkflowAccess( + credentials, + decision, + workflowId, + genericPermission, + conditionTransformer, + permissionsSvc, + logger, + ); +}; + +/** + * Asserts that the caller isn't outright denied `genericPermission`, throwing + * `NotAllowedError` on a generic `DENY`. Used by MCP actions that list + * multiple workflows/instances (`list-workflows`, `list-instances`) before + * narrowing down to specific ids via `filterAuthorizedWorkflowIds`, since an + * outright deny must surface as an error rather than silently filter down to + * an empty result. + */ +export const assertAnyWorkflowAccess = async ( + credentials: BackstageCredentials, + permissionsSvc: PermissionsService, + genericPermission: ResourcePermission<'orchestrator-workflow'>, +): Promise => { + const decision = await authorize( + credentials, + genericPermission, + permissionsSvc, + ); + + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError( + `Access denied by permission "${genericPermission.name}"`, + ); + } +}; + +/** + * Asserts that the caller either holds `orchestrator.instanceAdminView` or + * is the entity that initiated the given instance. Mirrors the ownership + * check in `service/router.ts`'s `getInstanceById` handler. + */ +export const assertInstanceOwnership = async ( + credentials: BackstageCredentials, + permissionsSvc: PermissionsService, + userInfo: UserInfoService, + instance: { initiatorEntity?: string }, + instanceId: string, +): Promise => { + const [adminDecision] = await permissionsSvc.authorize( + [{ permission: orchestratorInstanceAdminViewPermission }], + { credentials }, + ); + + if (adminDecision.result === AuthorizeResult.ALLOW) { + return; + } + + const { userEntityRef } = await userInfo.getUserInfo(credentials); + const instanceInitiatorEntity = instance.initiatorEntity; + + // If the instance has no initiatorEntity recorded, we cannot determine + // ownership. This can happen for: + // 1. Workflow instances created before the initiatorEntity feature was added + // 2. Workflow instances started externally (not through Backstage) + // 3. Workflows that transform/overwrite their input variables + if (!instanceInitiatorEntity) { + throw new NotAllowedError( + `Access denied for instance ${instanceId}. This workflow run does not have ` + + `ownership information recorded, so it cannot be verified that you initiated it. ` + + `The 'orchestrator.instanceAdminView' permission is required to view it.`, + ); + } + + if (instanceInitiatorEntity !== userEntityRef) { + throw new NotAllowedError( + `Access denied for instance ${instanceId}. This workflow run was initiated by ` + + `'${instanceInitiatorEntity}', not by you ('${userEntityRef}'). To view instances ` + + `initiated by others, you need the 'orchestrator.instanceAdminView' permission.`, + ); + } +}; + +/** + * Builds a `Filter` that restricts instance queries to the ones initiated by + * `initiatorEntity`, combined with `existingFilter` (if any) via `AND`. + * Mirrors the filter-building block in `service/router.ts`'s `getInstances` + * handler. + */ +export const buildInstanceOwnershipFilter = ( + initiatorEntity: string, + existingFilter?: Filter, +): Filter => { + const initiatorEntityFilter: FieldFilter = { + operator: 'EQ', + value: initiatorEntity, + field: 'initiatorEntity', + }; + + const nestedVariablesFilter: NestedFilter = { + field: 'variables', + nested: initiatorEntityFilter, + }; + + if (existingFilter === undefined) { + return nestedVariablesFilter; + } + + return { + operator: 'AND', + filters: [nestedVariablesFilter, existingFilter], + }; +}; diff --git a/workspaces/orchestrator/yarn.lock b/workspaces/orchestrator/yarn.lock index 6fd6e70f3fe..3b145ff2143 100644 --- a/workspaces/orchestrator/yarn.lock +++ b/workspaces/orchestrator/yarn.lock @@ -4424,6 +4424,26 @@ __metadata: languageName: node linkType: hard +"@backstage/plugin-mcp-actions-backend@npm:^0.1.14": + version: 0.1.14 + resolution: "@backstage/plugin-mcp-actions-backend@npm:0.1.14" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.9.2" + "@backstage/catalog-client": "npm:^1.16.0" + "@backstage/config": "npm:^1.3.8" + "@backstage/errors": "npm:^1.3.1" + "@backstage/plugin-catalog-node": "npm:^2.2.2" + "@backstage/types": "npm:^1.2.2" + "@cfworker/json-schema": "npm:^4.1.1" + "@modelcontextprotocol/sdk": "npm:^1.25.2" + express: "npm:^4.22.0" + express-promise-router: "npm:^4.1.0" + minimatch: "npm:^10.2.1" + zod: "npm:^3.25.76 || ^4.0.0" + checksum: 10c0/762c3696db2d6f3906872fb089cf3ea8844d5cf89741252c48bc8cce266cd15b2602550febc47f0fa560cf4ead4f1fbb7cbe3b84e54dae3fcafa8d2b6416efd9 + languageName: node + linkType: hard + "@backstage/plugin-notifications-backend@npm:^0.6.6": version: 0.6.7 resolution: "@backstage/plugin-notifications-backend@npm:0.6.7" @@ -5615,6 +5635,13 @@ __metadata: languageName: node linkType: hard +"@cfworker/json-schema@npm:^4.1.1": + version: 4.1.1 + resolution: "@cfworker/json-schema@npm:4.1.1" + checksum: 10c0/b5253486d346b7de6feec9c73954f612b11019dacb9023d710a5666df2f5fc145dd88b6b913c88726c6d97e2e258a515fa2cab177f58b18da6bac3738cbc4739 + languageName: node + linkType: hard + "@changesets/apply-release-plan@npm:^7.0.5": version: 7.0.5 resolution: "@changesets/apply-release-plan@npm:7.0.5" @@ -7232,6 +7259,15 @@ __metadata: languageName: node linkType: hard +"@hono/node-server@npm:^1.19.9 || ^2.0.5": + version: 2.0.12 + resolution: "@hono/node-server@npm:2.0.12" + peerDependencies: + hono: ^4 + checksum: 10c0/c3f56e286ddf81394cab02f74391ebead7d601c1140f2bbcafe24e09b49ceb77519982965b0e069b0c2c9ad1840cba088ac67844a6c98b356f7eedf28801db0d + languageName: node + linkType: hard + "@httptoolkit/httpolyglot@npm:^2.2.1": version: 2.2.2 resolution: "@httptoolkit/httpolyglot@npm:2.2.2" @@ -8507,6 +8543,39 @@ __metadata: languageName: node linkType: hard +"@modelcontextprotocol/sdk@npm:^1.25.2": + version: 1.30.0 + resolution: "@modelcontextprotocol/sdk@npm:1.30.0" + dependencies: + "@hono/node-server": "npm:^1.19.9 || ^2.0.5" + ajv: "npm:^8.17.1" + ajv-formats: "npm:^3.0.1" + content-type: "npm:^1.0.5" + cors: "npm:^2.8.5" + cross-spawn: "npm:^7.0.5" + eventsource: "npm:^3.0.2" + eventsource-parser: "npm:^3.0.0" + express: "npm:^5.2.1" + express-rate-limit: "npm:^8.2.1" + hono: "npm:^4.11.4" + jose: "npm:^6.1.3" + json-schema-typed: "npm:^8.0.2" + pkce-challenge: "npm:^5.0.0" + raw-body: "npm:^3.0.0" + zod: "npm:^3.25 || ^4.0" + zod-to-json-schema: "npm:^3.25.1" + peerDependencies: + "@cfworker/json-schema": ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + "@cfworker/json-schema": + optional: true + zod: + optional: false + checksum: 10c0/6d12d5c88048b42b0d64906222f804eecbfc0aae02fc42a200d2438f037276cc7863510a8ce649e82c96d5972f62a58bfbdedf79624b0558a607738f5e58b97e + languageName: node + linkType: hard + "@module-federation/bridge-react-webpack-plugin@npm:2.5.0": version: 2.5.0 resolution: "@module-federation/bridge-react-webpack-plugin@npm:2.5.0" @@ -11009,12 +11078,14 @@ __metadata: "@backstage/errors": "npm:^1.3.1" "@backstage/integration": "npm:^2.0.3" "@backstage/plugin-catalog-node": "npm:^2.2.2" + "@backstage/plugin-mcp-actions-backend": "npm:^0.1.14" "@backstage/plugin-permission-common": "npm:^0.9.9" "@backstage/plugin-permission-node": "npm:^0.11.1" "@backstage/plugin-scaffolder-backend": "npm:^4.0.1" "@backstage/plugin-scaffolder-node": "npm:^0.13.4" "@janus-idp/backstage-plugin-audit-log-node": "npm:^1.7.1" "@janus-idp/cli": "npm:3.7.0" + "@modelcontextprotocol/sdk": "npm:^1.25.2" "@red-hat-developer-hub/backstage-plugin-orchestrator-common": "workspace:^" "@red-hat-developer-hub/backstage-plugin-orchestrator-node": "workspace:^" "@types/express": "npm:4.17.25" @@ -11023,6 +11094,7 @@ __metadata: "@types/luxon": "npm:^3.7.1" "@types/supertest": "npm:^7.2.0" "@urql/core": "npm:^6.0.1" + ajv: "npm:^8.17.1" ajv-formats: "npm:^2.1.1" cloudevents: "npm:^10.0.0" express: "npm:^4.21.2" @@ -17434,6 +17506,7 @@ __metadata: "@backstage/plugin-catalog-backend-module-gitlab": "npm:^0.8.4" "@backstage/plugin-catalog-backend-module-logs": "npm:^0.1.23" "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "npm:^0.2.21" + "@backstage/plugin-mcp-actions-backend": "npm:^0.1.14" "@backstage/plugin-notifications-backend": "npm:^0.6.6" "@backstage/plugin-permission-backend": "npm:^0.7.13" "@backstage/plugin-permission-backend-module-allow-all-policy": "npm:^0.2.20" @@ -19562,7 +19635,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.5, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -22002,6 +22075,22 @@ __metadata: languageName: node linkType: hard +"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.1": + version: 3.1.0 + resolution: "eventsource-parser@npm:3.1.0" + checksum: 10c0/5ab4c6c9a2a042be0b387b6d03810eb580bac4ce90e299ede56458125a97ffe3af8145b2740089fc898a96cfa5aae792ee79f2a06257fba2776b0e7bce037071 + languageName: node + linkType: hard + +"eventsource@npm:^3.0.2": + version: 3.0.7 + resolution: "eventsource@npm:3.0.7" + dependencies: + eventsource-parser: "npm:^3.0.1" + checksum: 10c0/c48a73c38f300e33e9f11375d4ee969f25cbb0519608a12378a38068055ae8b55b6e0e8a49c3f91c784068434efe1d9f01eb49b6315b04b0da9157879ce2f67d + languageName: node + linkType: hard + "evp_bytestokey@npm:^1.0.0, evp_bytestokey@npm:^1.0.3": version: 1.0.3 resolution: "evp_bytestokey@npm:1.0.3" @@ -22122,6 +22211,18 @@ __metadata: languageName: node linkType: hard +"express-rate-limit@npm:^8.2.1": + version: 8.6.1 + resolution: "express-rate-limit@npm:8.6.1" + dependencies: + debug: "npm:^4.4.3" + ip-address: "npm:^10.2.0" + peerDependencies: + express: ">= 4.11" + checksum: 10c0/cb0e30283ef48925d8fe9efce6337a5877bb9a581f32f2594b08303dc075c24f1a52187e403c7f9889888d99eaabd9ac22182d2dadbd0b8acf3725e818131052 + languageName: node + linkType: hard + "express-rate-limit@npm:^8.2.2": version: 8.3.2 resolution: "express-rate-limit@npm:8.3.2" @@ -22188,7 +22289,7 @@ __metadata: languageName: node linkType: hard -"express@npm:^5.1.0": +"express@npm:^5.1.0, express@npm:^5.2.1": version: 5.2.1 resolution: "express@npm:5.2.1" dependencies: @@ -24007,6 +24108,13 @@ __metadata: languageName: node linkType: hard +"hono@npm:^4.11.4": + version: 4.12.32 + resolution: "hono@npm:4.12.32" + checksum: 10c0/3bb2fc0b042af87202e0cb0e5f77ee5b578684d868e1e321ed3de53e02f1ef02fe700811fa0d2fc765636a217bae7633d552d55934175f3dec6307a1f63f4ecb + languageName: node + linkType: hard + "hoopy@npm:^0.1.4": version: 0.1.4 resolution: "hoopy@npm:0.1.4" @@ -24634,6 +24742,13 @@ __metadata: languageName: node linkType: hard +"ip-address@npm:^10.2.0": + version: 10.3.1 + resolution: "ip-address@npm:10.3.1" + checksum: 10c0/45b1c31e2cc53d6354ea39f276d70c365a9b0332e7ca2140a9ad4cdb7fdb9d73b6a7ec0d8bf19993d8dce7ab636cd86c46c3e417b98db5ccb8c25546fe8c0b17 + languageName: node + linkType: hard + "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" @@ -25947,6 +26062,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^6.1.3": + version: 6.2.5 + resolution: "jose@npm:6.2.5" + checksum: 10c0/9bbb70f5d23052473e3629d3d11e07472f54c2f06318be5b6e9b016b1071589bdea18fd6e7559eea010081379a0f2d990599e1e5acaf7f37639833dfdd1d31ae + languageName: node + linkType: hard + "joycon@npm:^3.0.1": version: 3.1.1 resolution: "joycon@npm:3.1.1" @@ -26285,6 +26407,13 @@ __metadata: languageName: node linkType: hard +"json-schema-typed@npm:^8.0.2": + version: 8.0.2 + resolution: "json-schema-typed@npm:8.0.2" + checksum: 10c0/89f5e2fb1495483b705c027203c07277ee6bf2665165ad25a9cb55de5af7f72570326d13d32565180781e4083ad5c9688102f222baed7b353c2f39c1e02b0428 + languageName: node + linkType: hard + "json-schema@npm:^0.4.0": version: 0.4.0 resolution: "json-schema@npm:0.4.0" @@ -30591,6 +30720,13 @@ __metadata: languageName: node linkType: hard +"pkce-challenge@npm:^5.0.0": + version: 5.0.1 + resolution: "pkce-challenge@npm:5.0.1" + checksum: 10c0/207f4cb976682f27e8324eb49cf71937c98fbb8341a0b8f6142bc6f664825b30e049a54a21b5c034e823ee3c3d412f10d74bd21de78e17452a6a496c2991f57c + languageName: node + linkType: hard + "pkg-dir@npm:^4.2.0": version: 4.2.0 resolution: "pkg-dir@npm:4.2.0" @@ -31723,7 +31859,7 @@ __metadata: languageName: node linkType: hard -"raw-body@npm:^3.0.1": +"raw-body@npm:^3.0.0, raw-body@npm:^3.0.1": version: 3.0.2 resolution: "raw-body@npm:3.0.2" dependencies: @@ -38068,7 +38204,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.25.76 || ^4.0.0, zod@npm:^4.0.0, zod@npm:^4.3.6": +"zod@npm:^3.25 || ^4.0, zod@npm:^3.25.76 || ^4.0.0, zod@npm:^4.0.0, zod@npm:^4.3.6": version: 4.4.3 resolution: "zod@npm:4.4.3" checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3