Skip to content

Commit 12393ae

Browse files
authored
🌊 Streams: Handle missing permissions for fetching failure store information on listing page (elastic#235918)
This PR makes sure that the failure store is only queried if the logged in user has permissions to query it. It extends `internal/streams` to also return failure store read permissions - if available, it queries it, otherwise it shows a warning icon next to the affected columns: Old without permissions: <img width="1253" height="225" alt="Screenshot 2025-09-22 at 09 40 11" src="https://github.com/user-attachments/assets/0148d567-1316-4c65-b401-c590435d461d" /> New without permissions: <img width="735" height="492" alt="Screenshot 2025-09-22 at 09 36 16" src="https://github.com/user-attachments/assets/d8c0627e-8ffa-4026-b4d5-0d5b2940eac9" /> New with permissions (same as before) <img width="1020" height="434" alt="Screenshot 2025-09-22 at 09 37 01" src="https://github.com/user-attachments/assets/f62be846-f075-4e75-ac1e-f5bdb8aef0c4" />
1 parent d4656bb commit 12393ae

11 files changed

Lines changed: 125 additions & 40 deletions

File tree

‎x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/base.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ interface IngestStreamPrivileges {
2828
simulate: boolean;
2929
// User can get data information using the text structure API (e.g. to detect the structure of a message)
3030
text_structure: boolean;
31+
// User can read from the failure store
32+
read_failure_store: boolean;
3133
}
3234

3335
const ingestStreamPrivilegesSchema: z.Schema<IngestStreamPrivileges> = z.object({
@@ -36,6 +38,7 @@ const ingestStreamPrivilegesSchema: z.Schema<IngestStreamPrivileges> = z.object(
3638
lifecycle: z.boolean(),
3739
simulate: z.boolean(),
3840
text_structure: z.boolean(),
41+
read_failure_store: z.boolean(),
3942
});
4043

4144
export interface IngestBase {

‎x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/classic.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ describe('ClassicStream', () => {
9999
monitor: true,
100100
simulate: true,
101101
text_structure: true,
102+
read_failure_store: true,
102103
},
103104
data_stream_exists: true,
104105
...emptyAssets,

‎x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/wired.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ describe('WiredStream', () => {
9595
monitor: true,
9696
simulate: true,
9797
text_structure: true,
98+
read_failure_store: true,
9899
},
99100
effective_lifecycle: {
100101
dsl: {},

‎x-pack/platform/plugins/shared/streams/server/lib/streams/client.ts‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -492,11 +492,16 @@ export class StreamsClient {
492492
}
493493

494494
/**
495-
* Checks whether the user has the required privileges to manage the stream.
495+
* Checks whether the user has the required privileges to manage the stream (or streams).
496496
* Managing a stream means updating the stream properties. It does not
497497
* include the dashboard links.
498+
*
499+
* In case multiple streams are provided, it checks whether the user has
500+
* the required privileges on all streams, and returns the least-privileged
501+
* result.
498502
*/
499-
async getPrivileges(name: string) {
503+
async getPrivileges(nameOrNames: string | string[]) {
504+
const names = Array.isArray(nameOrNames) ? nameOrNames : [nameOrNames];
500505
const isServerless = this.dependencies.isServerless;
501506
const REQUIRED_MANAGE_PRIVILEGES = [
502507
'manage_index_templates',
@@ -516,6 +521,7 @@ export class StreamsClient {
516521
'manage',
517522
'monitor',
518523
'manage_data_stream_lifecycle',
524+
'read_failure_store',
519525
];
520526
if (!isServerless) {
521527
REQUIRED_INDEX_PRIVILEGES.push('manage_ilm');
@@ -526,7 +532,7 @@ export class StreamsClient {
526532
cluster: REQUIRED_MANAGE_PRIVILEGES,
527533
index: [
528534
{
529-
names: [name],
535+
names,
530536
privileges: REQUIRED_INDEX_PRIVILEGES,
531537
},
532538
],
@@ -535,15 +541,23 @@ export class StreamsClient {
535541
return {
536542
manage:
537543
REQUIRED_MANAGE_PRIVILEGES.every((privilege) => privileges.cluster[privilege] === true) &&
538-
Object.values(privileges.index[name]).every((privilege) => privilege === true),
539-
monitor: privileges.index[name].monitor,
544+
names.every((name) =>
545+
Object.values(privileges.index[name]).every((privilege) => privilege === true)
546+
),
547+
monitor: names.every((name) => privileges.index[name].monitor),
540548
// on serverless, there is no ILM, so we map lifecycle to true if the user has manage_data_stream_lifecycle
541549
lifecycle: isServerless
542-
? privileges.index[name].manage_data_stream_lifecycle
543-
: privileges.index[name].manage_data_stream_lifecycle && privileges.index[name].manage_ilm,
544-
simulate: privileges.cluster.read_pipeline && privileges.index[name].create,
550+
? names.every((name) => privileges.index[name].manage_data_stream_lifecycle)
551+
: names.every(
552+
(name) =>
553+
privileges.index[name].manage_data_stream_lifecycle &&
554+
privileges.index[name].manage_ilm
555+
),
556+
simulate:
557+
privileges.cluster.read_pipeline && names.every((name) => privileges.index[name].create),
545558
// text structure is always available for the internal user, but not for the current user
546559
text_structure: isServerless ? true : privileges.cluster.monitor_text_structure,
560+
read_failure_store: names.every((name) => privileges.index[name].read_failure_store),
547561
};
548562
}
549563

‎x-pack/platform/plugins/shared/streams/server/routes/internal/streams/crud/route.ts‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,29 @@ export const listStreamsRoute = createServerRoute({
3232
requiredPrivileges: [STREAMS_API_PRIVILEGES.read],
3333
},
3434
},
35-
handler: async ({ request, getScopedClients }): Promise<{ streams: ListStreamDetail[] }> => {
35+
handler: async ({
36+
request,
37+
getScopedClients,
38+
}): Promise<{ streams: ListStreamDetail[]; canReadFailureStore: boolean }> => {
3639
const { streamsClient, scopedClusterClient } = await getScopedClients({ request });
3740
const streams = await streamsClient.listStreamsWithDataStreamExistence();
3841

3942
const streamNames = streams.filter(({ exists }) => exists).map(({ stream }) => stream.name);
4043

41-
const dataStreams = await processAsyncInChunks(streamNames, (streamNamesChunk) =>
42-
scopedClusterClient.asCurrentUser.indices.getDataStream({ name: streamNamesChunk })
43-
);
44+
let canReadFailureStore = true;
45+
46+
const dataStreams = await processAsyncInChunks(streamNames, async (streamNamesChunk) => {
47+
const [{ read_failure_store: readFailureStore }, dataStreamsChunk] = await Promise.all([
48+
streamsClient.getPrivileges(streamNamesChunk),
49+
scopedClusterClient.asCurrentUser.indices.getDataStream({ name: streamNamesChunk }),
50+
]);
51+
52+
if (!readFailureStore) {
53+
canReadFailureStore = false;
54+
}
55+
56+
return dataStreamsChunk;
57+
});
4458

4559
const enrichedStreams = streams.reduce<ListStreamDetail[]>((acc, { stream }) => {
4660
if (Streams.GroupStream.Definition.is(stream)) {
@@ -57,7 +71,7 @@ export const listStreamsRoute = createServerRoute({
5771
return acc;
5872
}, []);
5973

60-
return { streams: enrichedStreams };
74+
return { streams: enrichedStreams, canReadFailureStore };
6175
},
6276
});
6377

‎x-pack/platform/plugins/shared/streams_app/public/components/stream_list_view/data_quality_column.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { mapPercentageToQuality } from '@kbn/dataset-quality-plugin/common';
1010
import { DatasetQualityIndicator, calculatePercentage } from '@kbn/dataset-quality-plugin/public';
1111
import useAsync from 'react-use/lib/useAsync';
1212
import { esqlResultToTimeseries } from '../../util/esql_result_to_timeseries';
13-
import type { StreamHistogramFetch } from '../../hooks/use_streams_histogram_fetch';
13+
import type { StreamHistogramFetch } from '../../hooks/use_doc_count_fetch';
1414

1515
export function DataQualityColumn({
1616
histogramQueryFetch,

‎x-pack/platform/plugins/shared/streams_app/public/components/stream_list_view/documents_column.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { esqlResultToTimeseries } from '../../util/esql_result_to_timeseries';
3131
import type { useTimefilter } from '../../hooks/use_timefilter';
3232
import { TooltipOrPopoverIcon } from '../tooltip_popover_icon/tooltip_popover_icon';
3333
import { getFormattedError } from '../../util/errors';
34-
import type { StreamHistogramFetch } from '../../hooks/use_streams_histogram_fetch';
34+
import type { StreamHistogramFetch } from '../../hooks/use_doc_count_fetch';
3535

3636
export function DocumentsColumn({
3737
indexPattern,

‎x-pack/platform/plugins/shared/streams_app/public/components/stream_list_view/index.tsx‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,10 @@ export function StreamListView() {
6161

6262
const { timeState } = useTimefilter();
6363
const streamsListFetch = useStreamsAppFetch(
64-
async ({ signal }) => {
65-
const { streams } = await streamsRepositoryClient.fetch('GET /internal/streams', {
64+
async ({ signal }) =>
65+
streamsRepositoryClient.fetch('GET /internal/streams', {
6666
signal,
67-
});
68-
return streams;
69-
},
67+
}),
7068
// time state change is used to trigger a refresh of the listed
7169
// streams metadata but we operate on stale data if we don't
7270
// also refresh the streams
@@ -91,7 +89,7 @@ export function StreamListView() {
9189
<StreamsAppContextProvider context={context}>
9290
<GroupStreamModificationFlyout
9391
client={streamsRepositoryClient}
94-
streamsList={streamsListFetch.value}
92+
streamsList={streamsListFetch.value?.streams}
9593
refresh={() => {
9694
streamsListFetch.refresh();
9795
overlayRef.current?.close();
@@ -185,11 +183,15 @@ export function StreamListView() {
185183
<StreamsListEmptyPrompt onAddData={handleAddData} />
186184
) : (
187185
<>
188-
<StreamsTreeTable loading={streamsListFetch.loading} streams={streamsListFetch.value} />
186+
<StreamsTreeTable
187+
loading={streamsListFetch.loading}
188+
streams={streamsListFetch.value?.streams}
189+
canReadFailureStore={streamsListFetch.value?.canReadFailureStore}
190+
/>
189191
{groupStreams?.enabled && (
190192
<>
191193
<EuiSpacer size="l" />
192-
<GroupStreamsCards streams={streamsListFetch.value} />
194+
<GroupStreamsCards streams={streamsListFetch.value?.streams} />
193195
</>
194196
)}
195197
</>

‎x-pack/platform/plugins/shared/streams_app/public/components/stream_list_view/translations.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ export const NAME_COLUMN_HEADER = i18n.translate('xpack.streams.streamsTreeTable
1111
defaultMessage: 'Name',
1212
});
1313

14+
export const FAILURE_STORE_PERMISSIONS_ERROR = i18n.translate(
15+
'xpack.streams.streamsTreeTable.failureStorePermissionsError',
16+
{
17+
defaultMessage:
18+
'Does not include failed documents - user does not have access to failure store',
19+
}
20+
);
21+
1422
export const DOCUMENTS_COLUMN_HEADER = i18n.translate(
1523
'xpack.streams.streamsTreeTable.documentsColumnName',
1624
{ defaultMessage: 'Documents' }

‎x-pack/platform/plugins/shared/streams_app/public/components/stream_list_view/tree_table.tsx‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
EuiInMemoryTable,
1616
useEuiTheme,
1717
EuiHighlight,
18+
EuiIconTip,
1819
EuiButtonIcon,
1920
} from '@elastic/eui';
2021
import { css } from '@emotion/css';
@@ -33,7 +34,7 @@ import { StreamsAppSearchBar } from '../streams_app_search_bar';
3334
import { DocumentsColumn } from './documents_column';
3435
import { DataQualityColumn } from './data_quality_column';
3536
import { useStreamsAppRouter } from '../../hooks/use_streams_app_router';
36-
import { useStreamHistogramFetch } from '../../hooks/use_streams_histogram_fetch';
37+
import { useDocCountFetch } from '../../hooks/use_doc_count_fetch';
3738
import { useTimefilter } from '../../hooks/use_timefilter';
3839
import { RetentionColumn } from './retention_column';
3940
import {
@@ -45,14 +46,17 @@ import {
4546
NO_STREAMS_MESSAGE,
4647
DATA_QUALITY_COLUMN_HEADER,
4748
DOCUMENTS_COLUMN_HEADER,
49+
FAILURE_STORE_PERMISSIONS_ERROR,
4850
} from './translations';
4951
import { DiscoverBadgeButton } from '../stream_badges';
5052

5153
export function StreamsTreeTable({
5254
loading,
5355
streams = [],
56+
canReadFailureStore = false,
5457
}: {
5558
streams?: ListStreamDetail[];
59+
canReadFailureStore?: boolean;
5660
loading?: boolean;
5761
}) {
5862
const router = useStreamsAppRouter();
@@ -161,7 +165,7 @@ export function StreamsTreeTable({
161165

162166
const numDataPoints = 25;
163167

164-
const { getStreamDocCounts } = useStreamHistogramFetch(numDataPoints);
168+
const { getStreamDocCounts } = useDocCountFetch({ numDataPoints, canReadFailureStore });
165169

166170
const sorting = {
167171
sort: {
@@ -283,7 +287,18 @@ export function StreamsTreeTable({
283287
},
284288
{
285289
field: 'documentsCount',
286-
name: DOCUMENTS_COLUMN_HEADER,
290+
name: (
291+
<EuiFlexGroup alignItems="center" gutterSize="s">
292+
{!canReadFailureStore && (
293+
<EuiIconTip
294+
content={FAILURE_STORE_PERMISSIONS_ERROR}
295+
type="warning"
296+
color="warning"
297+
/>
298+
)}
299+
{DOCUMENTS_COLUMN_HEADER}
300+
</EuiFlexGroup>
301+
),
287302
width: '180px',
288303
sortable: false,
289304
align: 'right',
@@ -300,7 +315,18 @@ export function StreamsTreeTable({
300315
},
301316
{
302317
field: 'dataQuality',
303-
name: DATA_QUALITY_COLUMN_HEADER,
318+
name: (
319+
<EuiFlexGroup alignItems="center" gutterSize="s">
320+
{!canReadFailureStore && (
321+
<EuiIconTip
322+
content={FAILURE_STORE_PERMISSIONS_ERROR}
323+
type="warning"
324+
color="warning"
325+
/>
326+
)}
327+
{DATA_QUALITY_COLUMN_HEADER}
328+
</EuiFlexGroup>
329+
),
304330
width: '150px',
305331
sortable: false,
306332
dataType: 'number',

0 commit comments

Comments
 (0)