Skip to content

Commit 3790a2e

Browse files
authored
[One Workflow] Add managed workflow badge and read-only restrictions (#271160)
## Summary - Adds a managed badge to workflow list and detail views for managed workflows. - Makes managed workflow YAML read-only while keeping the enable/disable toggle editable. - Prevents managed workflows from being deleted through row actions or bulk actions, and explains the disabled row delete action with managed-specific hover text. - Covers the new badge, read-only, listing, and delete behavior with focused workflows UI/server tests. <img width="1684" height="1122" alt="image" src="https://github.com/user-attachments/assets/e7c5a2d4-11ab-49e1-848e-b12687a0a5ca" /> <img width="1684" height="1122" alt="image" src="https://github.com/user-attachments/assets/a3881f06-baf8-4494-bfd9-476b35be50f2" /> <img width="1299" height="211" alt="image" src="https://github.com/user-attachments/assets/29e2c0f5-f8c7-46d7-b639-c831e7997e9f" /> <img width="1283" height="280" alt="image" src="https://github.com/user-attachments/assets/207829ae-d626-44ab-86b2-e7c73e40e6b4" /> ## Test plan - [x] node scripts/jest src/platform/plugins/shared/workflows_management/public/widgets/workflow_yaml_editor/ui/workflow_yaml_editor.test.tsx src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.test.tsx src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list.test.tsx - [x] node scripts/jest src/platform/plugins/shared/workflows_management/server/services/workflow_search_service.test.ts - [x] node scripts/jest src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list.authorization.test.tsx src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/use_workflow_bulk_actions.test.tsx - [x] node scripts/check_changes.ts ## References Closes elastic/security-team#17549
1 parent 2a3780d commit 3790a2e

12 files changed

Lines changed: 371 additions & 43 deletions

File tree

src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/use_workflow_bulk_actions.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ describe('useWorkflowBulkActions', () => {
101101

102102
beforeEach(() => {
103103
jest.clearAllMocks();
104+
mockApplication.capabilities.workflowsManagement.deleteWorkflow = true;
105+
mockApplication.capabilities.workflowsManagement.updateWorkflow = true;
106+
mockApplication.capabilities.workflowsManagement.readWorkflow = true;
104107
});
105108

106109
it('returns panels and modals', () => {
@@ -158,6 +161,25 @@ describe('useWorkflowBulkActions', () => {
158161
expect(deleteItem).toBeDefined();
159162
});
160163

164+
it('does not include delete action when a managed workflow is selected', () => {
165+
const managedWorkflow = createMockWorkflow({ managed: true });
166+
const { result } = renderHook(
167+
() =>
168+
useWorkflowBulkActions({
169+
...defaultProps,
170+
selectedWorkflows: [managedWorkflow],
171+
allWorkflows: [managedWorkflow],
172+
}),
173+
{ wrapper }
174+
);
175+
176+
const mainPanel = result.current.panels[0];
177+
const deleteItem = mainPanel.items?.find(
178+
(item) => 'key' in item && item.key === 'workflows-bulk-action-delete'
179+
);
180+
expect(deleteItem).toBeUndefined();
181+
});
182+
161183
it('includes export action when there are exportable workflows', () => {
162184
const { result } = renderHook(() => useWorkflowBulkActions(defaultProps), { wrapper });
163185

src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/use_workflow_bulk_actions.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,27 @@ export const useWorkflowBulkActions = ({
6969
const canReadWorkflow = application?.capabilities.workflowsManagement.readWorkflow;
7070

7171
const isDisabled = selectedWorkflows.length === 0;
72+
const hasManagedWorkflows = selectedWorkflows.some((workflow) => workflow.managed === true);
73+
const canDeleteSelectedWorkflows = canDeleteWorkflow && !hasManagedWorkflows;
7274

7375
const handleDeleteWorkflows = useCallback(() => {
76+
if (hasManagedWorkflows) {
77+
return;
78+
}
7479
onAction();
7580
setShowDeleteModal(true);
76-
}, [onAction]);
81+
}, [hasManagedWorkflows, onAction]);
7782

7883
const confirmDelete = useCallback(() => {
79-
const ids = selectedWorkflows.map((workflow) => workflow.id);
84+
const ids = selectedWorkflows
85+
.filter((workflow) => workflow.managed !== true)
86+
.map((workflow) => workflow.id);
8087
const count = ids.length;
88+
if (count === 0) {
89+
setShowDeleteModal(false);
90+
deselectWorkflows();
91+
return;
92+
}
8193

8294
setShowDeleteModal(false);
8395
deselectWorkflows();
@@ -231,15 +243,15 @@ export const useWorkflowBulkActions = ({
231243
});
232244
}
233245

234-
if (mainPanelItems.length > 0 && canDeleteWorkflow) {
246+
if (mainPanelItems.length > 0 && canDeleteSelectedWorkflows) {
235247
mainPanelItems.push({
236248
isSeparator: true as const,
237249
key: 'bulk-actions-separator',
238250
'data-test-subj': 'bulk-actions-separator',
239251
});
240252
}
241253

242-
if (canDeleteWorkflow) {
254+
if (canDeleteSelectedWorkflows) {
243255
mainPanelItems.push({
244256
name: (
245257
<EuiTextColor color="danger">
@@ -268,7 +280,7 @@ export const useWorkflowBulkActions = ({
268280
}, [
269281
selectedWorkflows,
270282
canUpdateWorkflow,
271-
canDeleteWorkflow,
283+
canDeleteSelectedWorkflows,
272284
canReadWorkflow,
273285
isDisabled,
274286
handleEnableWorkflows,

src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list.authorization.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,36 @@ describe('Authorization matrix', () => {
284284
}
285285
);
286286

287+
it('disables the delete row action for managed workflows', async () => {
288+
setKibanaCapabilities({
289+
createWorkflow: true,
290+
updateWorkflow: true,
291+
deleteWorkflow: true,
292+
executeWorkflow: true,
293+
});
294+
renderList({ item: createWorkflowListItem({ id: 'managed-wf', managed: true }) });
295+
296+
await openFirstRowCollapsedActions();
297+
298+
expect(screen.getByTestId('deleteWorkflowAction')).toBeDisabled();
299+
});
300+
301+
it('explains why managed workflows cannot be deleted', async () => {
302+
setKibanaCapabilities({
303+
createWorkflow: true,
304+
updateWorkflow: true,
305+
deleteWorkflow: true,
306+
executeWorkflow: true,
307+
});
308+
renderList({ item: createWorkflowListItem({ id: 'managed-wf', managed: true }) });
309+
310+
await openFirstRowCollapsedActions();
311+
const deleteAction = screen.getByTestId('deleteWorkflowAction');
312+
await userEvent.hover(deleteAction.parentElement ?? deleteAction);
313+
314+
expect(await screen.findByText('Managed workflows cannot be deleted')).toBeInTheDocument();
315+
});
316+
287317
it('disables the enabled switch when the workflow is invalid even if update is granted', () => {
288318
setKibanaCapabilities({
289319
createWorkflow: false,

src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list.test.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* License v3.0 only", or the "Server Side Public License, v 1".
88
*/
99

10-
import { render, screen } from '@testing-library/react';
10+
import { render, screen, within } from '@testing-library/react';
1111
import React from 'react';
1212
import type { WorkflowListDto, WorkflowListItemDto, WorkflowsSearchParams } from '@kbn/workflows';
1313
import { createMockWorkflowsCapabilities as mockCreateMockWorkflowsCapabilities } from '@kbn/workflows-ui/mocks';
@@ -106,6 +106,9 @@ jest.mock('../../run_workflow/ui/workflow_execute_modal', () => ({
106106

107107
jest.mock('../../../shared/ui', () => ({
108108
getRunTooltipContent: () => 'Run',
109+
ManagedWorkflowBadge: ({ dataTestSubj = 'managedWorkflowBadge' }: { dataTestSubj?: string }) => (
110+
<span data-test-subj={dataTestSubj}>{'Managed'}</span>
111+
),
109112
StatusBadge: ({ status }: { status: string }) => <span>{status}</span>,
110113
WorkflowStatus: ({ valid }: { valid: boolean }) => <span>{valid ? 'Valid' : 'Invalid'}</span>,
111114
}));
@@ -287,6 +290,38 @@ describe('WorkflowList', () => {
287290
expect(screen.getByText('A workflow for testing')).toBeInTheDocument();
288291
});
289292

293+
it('renders the managed badge as the first tag for managed workflows', () => {
294+
const workflow = createMockWorkflow({
295+
managed: true,
296+
definition: {
297+
version: '1',
298+
name: 'My Test Workflow',
299+
enabled: true,
300+
triggers: [],
301+
steps: [],
302+
tags: ['custom', 'second'],
303+
},
304+
});
305+
306+
mockUseWorkflows.mockReturnValue({
307+
data: createMockWorkflowListDto([workflow]),
308+
isLoading: false,
309+
error: null,
310+
refetch: mockRefetch,
311+
});
312+
313+
renderComponent();
314+
315+
const tagsCell = screen.getByTestId('workflowTags');
316+
const managedBadge = within(tagsCell).getByTestId('managedWorkflowBadge');
317+
const firstWorkflowTag = within(tagsCell).getByText('custom');
318+
319+
expect(managedBadge).toHaveTextContent('Managed');
320+
expect(managedBadge.compareDocumentPosition(firstWorkflowTag)).toEqual(
321+
Node.DOCUMENT_POSITION_FOLLOWING
322+
);
323+
});
324+
290325
it('shows "No description" for workflows without description', () => {
291326
mockUseWorkflows.mockReturnValue({
292327
data: createMockWorkflowListDto([createMockWorkflow({ description: '' })]),

src/platform/plugins/shared/workflows_management/public/features/workflow_list/ui/workflow_list_table.tsx

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@ import { i18n } from '@kbn/i18n';
2929
import { FormattedMessage } from '@kbn/i18n-react';
3030
import type { WorkflowListItemDto } from '@kbn/workflows';
3131
import { WorkflowTriggersAndSteps } from './workflow_triggers_and_steps';
32-
import { getRunTooltipContent, StatusBadge, WorkflowStatus } from '../../../shared/ui';
32+
import {
33+
getRunTooltipContent,
34+
ManagedWorkflowBadge,
35+
StatusBadge,
36+
WorkflowStatus,
37+
} from '../../../shared/ui';
3338
import { NextExecutionTime } from '../../../shared/ui/next_execution_time';
3439
import { WORKFLOWS_TABLE_PAGE_SIZE_OPTIONS } from '../constants';
3540

@@ -171,7 +176,7 @@ export const WorkflowListTable = ({
171176
}),
172177
width: '18%',
173178
render: (value: unknown, item: WorkflowListItemDto) => (
174-
<WorkflowTagsCell tags={item.definition?.tags} />
179+
<WorkflowTagsCell tags={item.definition?.tags} isManaged={item.managed === true} />
175180
),
176181
},
177182
{
@@ -324,15 +329,20 @@ export const WorkflowListTable = ({
324329
onClick: (item: WorkflowListItemDto) => onExportWorkflow(item),
325330
},
326331
{
327-
enabled: () => canDeleteWorkflow,
332+
enabled: (item) => canDeleteWorkflow && item.managed !== true,
328333
type: 'icon',
329334
color: 'danger',
330335
name: i18n.translate('workflows.workflowList.delete', { defaultMessage: 'Delete' }),
331336
'data-test-subj': 'deleteWorkflowAction',
332337
icon: 'trash',
333-
description: i18n.translate('workflows.workflowList.delete', {
334-
defaultMessage: 'Delete workflow',
335-
}),
338+
description: (item: WorkflowListItemDto) =>
339+
item.managed === true
340+
? i18n.translate('workflows.workflowList.deleteManagedDisabled', {
341+
defaultMessage: 'Managed workflows cannot be deleted',
342+
})
343+
: i18n.translate('workflows.workflowList.deleteDescription', {
344+
defaultMessage: 'Delete workflow',
345+
}),
336346
onClick: (item: WorkflowListItemDto) => onDeleteWorkflow(item),
337347
},
338348
],
@@ -416,15 +426,25 @@ const overflowPopoverStyle = css`
416426
overflow: auto;
417427
`;
418428

419-
const WorkflowTagsCell = ({ tags }: { tags: readonly string[] | undefined }) => {
429+
const WorkflowTagsCell = ({
430+
tags,
431+
isManaged,
432+
}: {
433+
tags: readonly string[] | undefined;
434+
isManaged: boolean;
435+
}) => {
420436
const [isOpen, setIsOpen] = useState(false);
421437
const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
422438
const close = useCallback(() => setIsOpen(false), []);
423439

424-
if (!tags || tags.length === 0) return null;
440+
if (!isManaged && (!tags || tags.length === 0)) return null;
425441

426-
const visible = tags.slice(0, MAX_VISIBLE_TAGS);
427-
const hidden = tags.slice(MAX_VISIBLE_TAGS);
442+
const workflowTags = tags ?? [];
443+
const visibleWorkflowTags = workflowTags.slice(
444+
0,
445+
isManaged ? MAX_VISIBLE_TAGS - 1 : MAX_VISIBLE_TAGS
446+
);
447+
const hidden = workflowTags.slice(visibleWorkflowTags.length);
428448

429449
return (
430450
<EuiFlexGroup
@@ -434,7 +454,12 @@ const WorkflowTagsCell = ({ tags }: { tags: readonly string[] | undefined }) =>
434454
css={tagsRowStyle}
435455
data-test-subj="workflowTags"
436456
>
437-
{visible.map((tag) => (
457+
{isManaged ? (
458+
<EuiFlexItem grow={false} css={visibleTagStyle}>
459+
<ManagedWorkflowBadge />
460+
</EuiFlexItem>
461+
) : null}
462+
{visibleWorkflowTags.map((tag) => (
438463
<EuiFlexItem key={tag} grow={false} css={visibleTagStyle}>
439464
<EuiBadge color="hollow" title={tag}>
440465
{tag}

src/platform/plugins/shared/workflows_management/public/pages/workflow_detail/ui/workflow_detail_header.test.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,18 +91,20 @@ describe('WorkflowDetailHeader', () => {
9191
hasYamlSchemaValidationErrors = false,
9292
serverValid = true,
9393
isSaving = false,
94+
isManaged = false,
9495
}: {
9596
isValid?: boolean;
9697
hasChanges?: boolean;
9798
hasYamlSchemaValidationErrors?: boolean;
9899
serverValid?: boolean;
99100
isSaving?: boolean;
101+
isManaged?: boolean;
100102
} = {}
101103
) => {
102104
const store = createMockStore();
103105

104106
// Set up the workflow in the store (with server-side valid flag)
105-
store.dispatch(setWorkflow({ ...mockWorkflow, valid: serverValid }));
107+
store.dispatch(setWorkflow({ ...mockWorkflow, managed: isManaged, valid: serverValid }));
106108
store.dispatch(setYamlString(hasChanges ? 'modified yaml' : mockWorkflow.yaml));
107109

108110
if (!isValid) {
@@ -216,6 +218,31 @@ describe('WorkflowDetailHeader', () => {
216218
expect(button).toBeEnabled();
217219
});
218220

221+
it('shows the managed badge for managed workflows', () => {
222+
const result = renderWithProviders(<WorkflowDetailHeader {...defaultProps} />, {
223+
isManaged: true,
224+
});
225+
226+
expect(result.getByTestId('workflowDetailManagedBadge')).toHaveTextContent('Managed');
227+
});
228+
229+
it('keeps the enabled toggle editable for managed workflows', () => {
230+
const result = renderWithProviders(<WorkflowDetailHeader {...defaultProps} />, {
231+
isManaged: true,
232+
});
233+
234+
expect(result.getByRole('switch')).not.toBeDisabled();
235+
});
236+
237+
it('disables saving managed workflow YAML', () => {
238+
const result = renderWithProviders(<WorkflowDetailHeader {...defaultProps} />, {
239+
hasChanges: true,
240+
isManaged: true,
241+
});
242+
243+
expect(result.getByTestId('saveWorkflowHeaderButton')).toBeDisabled();
244+
});
245+
219246
it('shows the unsaved changes confirmation when running with unsaved changes', () => {
220247
const result = renderWithProviders(<WorkflowDetailHeader {...defaultProps} />, {
221248
hasChanges: true,

0 commit comments

Comments
 (0)