Skip to content

Commit 86b2e03

Browse files
[One workflows] UI authorization gates (elastic#260260)
Closes elastic/security-team#16110 This PR aligns the Workflows UI with Kibana **Workflows** feature sub-privileges (`application.capabilities.workflowsManagement.*`) and related signals so users are not offered actions that reliably fail with **403**. --- ## Summary Below is a consolidated inventory of **gates implemented** (by surface). Capability names match the UI keys from `WorkflowsManagementUiActions` in `@kbn/workflows/common/privileges`. ### 1. Application shell | Surface | Control / behavior | Required capability or condition | Primary code | | ------- | ------------------ | -------------------------------- | ------------ | | Workflows app | All routes (`/`, `/create`, `/:id`) | `readWorkflow` (`useWorkflowsCapabilities`), otherwise **Access Denied** (no child routes mount) | `public/routes.tsx` (`WorkflowsReadPermissionsWrapper`) | --- ### 2. List page and table | Surface | Control / behavior | Required capability | Primary code | | ------- | ------------------ | ------------------- | ------------ | | List header | **Create** workflow | `createWorkflow` | `pages/workflows/index.tsx` | | List header | **Import** | `createWorkflow` | `pages/workflows/index.tsx` | | List table | Row **Enabled** toggle | `updateWorkflow` | `features/workflow_list/ui/workflow_list.tsx` | | List table | Row **Run** | `executeWorkflow` (+ workflow enabled/valid) | `workflow_list.tsx` | | List table | Row **Edit** | `updateWorkflow` | `workflow_list.tsx` | | List table | Row **Clone** | `createWorkflow` | `workflow_list.tsx` | | List table | Row **Delete** | `deleteWorkflow` | `workflow_list.tsx` | | List table | Bulk **Enable** / **Disable** | `updateWorkflow` | `use_workflow_bulk_actions.tsx` | | List table | Bulk **Delete** | `deleteWorkflow` | `use_workflow_bulk_actions.tsx` | | Empty state | Create affordance | `createWorkflow` (from parent) | `components/workflows_empty_state/workflows_empty_state.tsx` | **Note:** **Export** (row and bulk) relies on app-level `readWorkflow`; there is no separate export capability toggle in the UI. --- ### 3. Workflow detail — header and page | Surface | Control / behavior | Required capability | Primary code | | ------- | ------------------ | ------------------- | ------------ | | Detail header | **Executions** tab | `readWorkflowExecution` | `pages/workflow_detail/ui/workflow_detail_header.tsx` | | Detail header | **Enabled** switch | `updateWorkflow` | `workflow_detail_header.tsx` | | Detail header | **Run** | `executeWorkflow` | `workflow_detail_header.tsx` | | Detail header | **Save** | `createWorkflow` (new) or `updateWorkflow` (existing) | `workflow_detail_header.tsx` | | Detail page | Executions **list** / **detail** UI | `readWorkflowExecution` | `workflow_detail_page.tsx` | --- ### 4. Run / test modals | Surface | Control / behavior | Required capability or signal | Primary code | | ------- | ------------------ | ----------------------------- | ------------ | | Full-workflow **test** modal | Modal open / run | `executeWorkflow` | `workflow_detail_test_modal.tsx` | | **Execute** modal (Run + test title variants) | **Historical** tab | `readWorkflowExecution` (disabled tab, tooltip, fallback initial tab) | `workflow_execute_modal.tsx`, `workflow_execute_modal_helpers.ts`, `workflow_execute_historical_form.tsx` under `features/run_workflow/ui/` | | **Execute** modal | **Alert** tab | RAC index prefetch / message patterns on **`onError`** disable the tab | `workflow_execute_modal.tsx` | | **Execute** modal | **Manual** / **Index** / **Document** | Execute path | `workflow_execute_modal.tsx` and related forms | --- ### 5. Editor — single step and YAML | Surface | Control / behavior | Required capability | Primary code | | ------- | ------------------ | ------------------- | ------------ | | YAML gutter | **Run step** | `executeWorkflow` (disabled + tooltip via shared helpers; denied → **Execute** privilege copy) | `widgets/workflow_yaml_editor/ui/run_step_button.tsx`, `shared/ui/workflow_action_buttons/get_workflow_tooltip_content.tsx` | | Editor | **Run single step** handler | `executeWorkflow` | `workflow_detail_editor.tsx` | | **Test step** modal | Render / use | `executeWorkflow` | `workflow_detail_test_step_modal.tsx` | --- ### 6. Execution detail | Surface | Control / behavior | Required capability | Primary code | | ------- | ------------------ | ------------------- | ------------ | | Execution panel | **Replay** / **Run again** | `executeWorkflow` (+ syntax valid) | `workflow_execution_panel.tsx` | | Execution panel | **Resume** | `executeWorkflow` | `resume_execution_button.tsx` | | Execution panel | **Cancel** | `cancelWorkflowExecution` | `cancel_execution_button.tsx` | --- ### 7. Agent Builder — workflow YAML attachment | Surface | Control / behavior | Required capability | Primary code | | ------- | ------------------ | ------------------- | ------------ | | Canvas (flyout) | **Save** (new YAML) | `createWorkflow` | `features/ai_integration/attachment_renderers/workflow_yaml_attachment_renderer.tsx` | | Canvas | **Save as new** | `createWorkflow` | Same | | Canvas | **Override** | `updateWorkflow` | Same | | Canvas | **Open in editor** | `readWorkflow` | Same | ## 🎥 Demo ### User without read privilege <img width="1788" height="1188" alt="Screenshot 2026-03-30 at 15 58 44" src="https://github.com/user-attachments/assets/38b0aba3-4103-4967-927d-f0c5b89a2b33" /> ### User with only read privilege https://github.com/user-attachments/assets/dc72c6b5-1b30-42d0-a222-ec82fd0fd811 ### User with read + update (no create) https://github.com/user-attachments/assets/55568770-cfce-4f51-abc3-c6c0ef9388aa ### User with read + create (no update) https://github.com/user-attachments/assets/8d73d89c-32dd-48a8-b499-825c423450a4 ### User with execution capabilities (no read executions) https://github.com/user-attachments/assets/bef9d6c4-275f-46ef-989a-0393f8a48885 ### User with full execution capabilities https://github.com/user-attachments/assets/0d27164e-8825-47cb-9432-03a374616b61 ### User with CRUD capabilites https://github.com/user-attachments/assets/f5e5b49d-0d86-4d68-8abc-0bcf480f2b30 ### Full privileges user https://github.com/user-attachments/assets/f4c29113-9373-4f09-bfd1-2d552d343b53 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
1 parent 74e99f2 commit 86b2e03

40 files changed

Lines changed: 2209 additions & 284 deletions

File tree

src/platform/packages/shared/kbn-alerts-ui-shared/src/common/hooks/use_fetch_alerts_index_names_query.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const useFetchAlertsIndexNamesQuery = (
2828
{ http, ruleTypeIds }: UseFetchAlertsIndexNamesQueryParams,
2929
options?: Pick<
3030
QueryOptionsOverrides<typeof fetchAlertsIndexNames>,
31-
'context' | 'onError' | 'refetchOnWindowFocus' | 'staleTime' | 'enabled'
31+
'context' | 'enabled' | 'onError' | 'refetchOnWindowFocus' | 'retry' | 'staleTime'
3232
>
3333
) => {
3434
return useQuery({

src/platform/plugins/shared/workflows_management/common/components/access_denied.tsx

Lines changed: 0 additions & 50 deletions
This file was deleted.
Lines changed: 13 additions & 0 deletions
Loading
Lines changed: 13 additions & 0 deletions
Loading
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import { EuiProvider } from '@elastic/eui';
11+
import { render, screen } from '@testing-library/react';
12+
import React from 'react';
13+
import { I18nProvider } from '@kbn/i18n-react';
14+
import { AccessDenied } from './access_denied';
15+
16+
jest.mock('@kbn/kibana-react-plugin/public', () => ({
17+
useKibana: () => ({
18+
services: {
19+
http: {
20+
basePath: {
21+
prepend: (path: string) => `/mock-base-path${path}`,
22+
},
23+
},
24+
},
25+
}),
26+
}));
27+
28+
jest.mock('../../hooks/use_workflow_breadcrumbs/use_workflow_breadcrumbs', () => ({
29+
useWorkflowsBreadcrumbs: jest.fn(),
30+
}));
31+
32+
const renderWithProviders = (component: React.ReactElement) =>
33+
render(
34+
<I18nProvider>
35+
<EuiProvider colorMode="light">{component}</EuiProvider>
36+
</I18nProvider>
37+
);
38+
39+
describe('AccessDenied', () => {
40+
it('renders no-read empty state copy and data-test-subj', () => {
41+
renderWithProviders(<AccessDenied />);
42+
43+
expect(screen.getByTestId('workflowsNoReadAccessEmptyState')).toBeInTheDocument();
44+
expect(screen.getByText('Contact your administrator for access')).toBeInTheDocument();
45+
expect(
46+
screen.getByText('To view workflows in this space, you need additional privileges.')
47+
).toBeInTheDocument();
48+
});
49+
50+
it('lists required privileges in the empty prompt footer when requirements are provided', () => {
51+
renderWithProviders(<AccessDenied requirements={['Workflows: Read']} />);
52+
53+
expect(
54+
screen.getByTestId('workflowsNoReadAccessRequiredPrivilegesSection')
55+
).toBeInTheDocument();
56+
expect(screen.getByText('Minimum privileges required in this space:')).toBeInTheDocument();
57+
expect(screen.getByText('Workflows: Read')).toBeInTheDocument();
58+
});
59+
60+
it('uses the light lock illustration in light mode', () => {
61+
renderWithProviders(<AccessDenied />);
62+
63+
const image = screen.getByRole('img', { name: 'Restricted access' });
64+
expect(image).toHaveAttribute(
65+
'src',
66+
'/mock-base-path/plugins/workflowsManagement/assets/lock_light.svg'
67+
);
68+
});
69+
});
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import {
11+
EuiBadge,
12+
EuiEmptyPrompt,
13+
EuiFlexGroup,
14+
EuiFlexItem,
15+
EuiHorizontalRule,
16+
EuiImage,
17+
EuiPageTemplate,
18+
EuiPanel,
19+
EuiText,
20+
type EuiThemeComputed,
21+
useEuiTheme,
22+
} from '@elastic/eui';
23+
import { css } from '@emotion/react';
24+
import React from 'react';
25+
import type { HttpSetup } from '@kbn/core/public';
26+
import { i18n } from '@kbn/i18n';
27+
import { FormattedMessage } from '@kbn/i18n-react';
28+
import { useKibana } from '@kbn/kibana-react-plugin/public';
29+
import { useWorkflowsBreadcrumbs } from '../../hooks/use_workflow_breadcrumbs/use_workflow_breadcrumbs';
30+
31+
const PROMPT_MAX_WIDTH_PX = 740;
32+
const LOCK_ASSET_BASE = '/plugins/workflowsManagement/assets';
33+
34+
const getEmptyPromptLayoutStyles = (
35+
euiTheme: EuiThemeComputed,
36+
options: { flushFooterToCard: boolean }
37+
) => css`
38+
&& > .euiEmptyPrompt__main {
39+
inline-size: min(100%, ${PROMPT_MAX_WIDTH_PX}px);
40+
padding-inline: ${euiTheme.size.xl};
41+
padding-block-start: ${euiTheme.size.xl};
42+
padding-block-end: ${euiTheme.size.xl};
43+
}
44+
${options.flushFooterToCard
45+
? `
46+
&& > .euiEmptyPrompt__footer {
47+
padding: 0;
48+
margin: 0;
49+
background: transparent;
50+
border: none;
51+
}
52+
`
53+
: ''}
54+
`;
55+
56+
const getPrivilegeBadgeStyles = (euiTheme: EuiThemeComputed) => css`
57+
padding-block: 0;
58+
padding-inline: ${euiTheme.size.xs};
59+
min-block-size: 0;
60+
line-height: ${euiTheme.size.m};
61+
`;
62+
63+
function getLockIllustrationSrc(http: HttpSetup | undefined, colorMode: string): string {
64+
const file = colorMode === 'LIGHT' ? 'lock_light.svg' : 'lock_dark.svg';
65+
return http?.basePath.prepend(`${LOCK_ASSET_BASE}/${file}`) ?? '';
66+
}
67+
68+
interface PrivilegesFooterProps {
69+
requirements: readonly string[];
70+
}
71+
72+
const PrivilegesFooter = ({ requirements }: PrivilegesFooterProps) => {
73+
const { euiTheme } = useEuiTheme();
74+
75+
return (
76+
<>
77+
<EuiHorizontalRule
78+
margin="none"
79+
css={{
80+
marginBlockStart: euiTheme.size.xs,
81+
marginBlockEnd: 0,
82+
}}
83+
/>
84+
<EuiPanel
85+
data-test-subj="workflowsNoReadAccessRequiredPrivilegesSection"
86+
color="subdued"
87+
borderRadius="none"
88+
paddingSize="none"
89+
hasBorder={false}
90+
hasShadow={false}
91+
css={{
92+
textAlign: 'center',
93+
borderBottomLeftRadius: euiTheme.border.radius.medium,
94+
borderBottomRightRadius: euiTheme.border.radius.medium,
95+
paddingBlockStart: euiTheme.size.s,
96+
paddingInline: euiTheme.size.m,
97+
paddingBlockEnd: euiTheme.size.m,
98+
}}
99+
>
100+
<EuiText color="subdued" textAlign="center" size="xs">
101+
<p css={{ marginBlock: 0 }}>
102+
<FormattedMessage
103+
id="platform.plugins.shared.workflows_management.ui.noReadAccess.requiredPrivileges"
104+
defaultMessage="Minimum privileges required in this space:"
105+
/>
106+
</p>
107+
</EuiText>
108+
<EuiFlexGroup
109+
gutterSize="xs"
110+
wrap
111+
justifyContent="center"
112+
responsive={false}
113+
css={{ marginBlockStart: euiTheme.size.xs }}
114+
>
115+
{requirements.map((requirement) => (
116+
<EuiFlexItem key={requirement} grow={false}>
117+
<EuiBadge color="hollow" css={getPrivilegeBadgeStyles(euiTheme)}>
118+
{requirement}
119+
</EuiBadge>
120+
</EuiFlexItem>
121+
))}
122+
</EuiFlexGroup>
123+
</EuiPanel>
124+
</>
125+
);
126+
};
127+
128+
export interface AccessDeniedProps {
129+
/** Human-readable privilege labels (e.g. UI action names) needed to use the app in this space. */
130+
requirements?: readonly string[];
131+
}
132+
133+
export const AccessDenied = ({ requirements }: AccessDeniedProps): JSX.Element => {
134+
useWorkflowsBreadcrumbs();
135+
const { euiTheme, colorMode } = useEuiTheme();
136+
const {
137+
services: { http },
138+
} = useKibana();
139+
140+
const lockSrc = getLockIllustrationSrc(http, colorMode);
141+
const hasPrivilegesFooter = Boolean(requirements?.length);
142+
143+
return (
144+
<EuiPageTemplate
145+
offset={0}
146+
paddingSize="none"
147+
grow
148+
css={{
149+
backgroundColor: euiTheme.colors.backgroundBaseSubdued,
150+
minHeight: 'var(--kbn-application--content-height, 100vh)',
151+
}}
152+
>
153+
<EuiPageTemplate.Section
154+
grow
155+
css={{
156+
display: 'flex',
157+
alignItems: 'center',
158+
justifyContent: 'center',
159+
}}
160+
>
161+
<EuiEmptyPrompt
162+
data-test-subj="workflowsNoReadAccessEmptyState"
163+
hasShadow
164+
color="plain"
165+
paddingSize="none"
166+
css={getEmptyPromptLayoutStyles(euiTheme, { flushFooterToCard: hasPrivilegesFooter })}
167+
icon={
168+
<EuiImage
169+
size="s"
170+
src={lockSrc}
171+
alt={i18n.translate(
172+
'platform.plugins.shared.workflows_management.ui.noReadAccess.illustrationAlt',
173+
{ defaultMessage: 'Restricted access' }
174+
)}
175+
/>
176+
}
177+
title={
178+
<h2>
179+
<FormattedMessage
180+
id="platform.plugins.shared.workflows_management.ui.noReadAccess.title"
181+
defaultMessage="Contact your administrator for access"
182+
/>
183+
</h2>
184+
}
185+
body={
186+
<p css={{ marginBlockEnd: 0, textAlign: 'center' }}>
187+
<FormattedMessage
188+
id="platform.plugins.shared.workflows_management.ui.noReadAccess.description"
189+
defaultMessage="To view workflows in this space, you need additional privileges."
190+
/>
191+
</p>
192+
}
193+
footer={
194+
hasPrivilegesFooter && requirements ? (
195+
<PrivilegesFooter requirements={requirements} />
196+
) : undefined
197+
}
198+
/>
199+
</EuiPageTemplate.Section>
200+
</EuiPageTemplate>
201+
);
202+
};

0 commit comments

Comments
 (0)