[8.x] 🌊 Streams: Permission handling (#217353) (#217520)

# Backport

This will backport the following commits from `main` to `8.x`:
- [🌊 Streams: Permission handling
(#217353)](https://github.com/elastic/kibana/pull/217353)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Joe
Reuter","email":"johannes.reuter@elastic.co"},"sourceCommit":{"committedDate":"2025-04-08T13:42:29Z","message":"🌊
Streams: Permission handling (#217353)\n\nCurrently, the streams UI
doesn't deal well with partial permissions.\nThis PR improves that. As a
lot of things come together in streams, we\ncould do even better, but I
think it's OK to draw a line somewhere.\n\nThe logic is now as
follows:\nWhen reading a stream, the privileges of the current user are
returned\nalong with the stream itself. These are grouped like
this:\n```\ninterface IngestStreamPrivileges {\n // User can change
everything about the stream\n manage: boolean;\n // User can read stats
(like size in bytes) about the stream\n monitor: boolean;\n // User can
change the retention policy of the stream\n lifecycle: boolean;\n //
User can simulate changes to the processing or the mapping of the
stream\n simulate: boolean;\n}\n```\n\nThis is part of the definition
response and is passed around to the\ncomponents and disabled buttons
and similar in the places where this is\nnecessary.\n\nThe \"advanced\"
tab is only shown when full `manage` permissions are\npresent - there
constellations of permissions that would allow some\naccess but not all
(e.g. having `read_pipelines` but not\n`manage_index_templates`), but
these should be rather rare and not worth\nthe additional effort.\n\n##
Conditions\n\nIn the following places privileges are checked:\n*
Overview\n * Without `monitor`, the overall stats are not shown\n*
Enrichment\n * Without `manage`, you can't save changes\n * Without
`simulate`, the UI is readonly\n* Partitioning\n * Without `manage`, you
can't save changes\n * Without `simulate`, the UI is readonly\n* Schema
editor\n * Without `manage`, the UI is readonly\n* Retention\n * Without
`monitor`, the ingest stats are not shown\n* Without `lifecycle`, the
retention can't be changed and ILM breakdown\nis not rendered\n*
Advanced\n * Without `manage`, the tab is hidden completely\n\n##
Drive-by fix\n\nI noticed that we still register the app header action
menu which adds\nan empty bar on serverless, removed that code.\n\n##
Testing\n\nCheck\nhttps://github.com/elastic/kibana/pull/217353/files#diff-d8f33d7021058bf90cbeea908bf399da2af50d8b8bfac8a07f160ddc0cdff12bR747\nfor
which Elasticsearch level privileges you need for
different\npermutations. Then set up a role and a user and log in as
that user.\n\nAlso test the different pre-defined roles on
serverless.","sha":"fd374463f74caac17b07120c34d2fc6c8e5e2754","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:obs-ux-logs","backport:version","Feature:Streams","v9.1.0","v8.19.0"],"title":"🌊
Streams: Permission
handling","number":217353,"url":"https://github.com/elastic/kibana/pull/217353","mergeCommit":{"message":"🌊
Streams: Permission handling (#217353)\n\nCurrently, the streams UI
doesn't deal well with partial permissions.\nThis PR improves that. As a
lot of things come together in streams, we\ncould do even better, but I
think it's OK to draw a line somewhere.\n\nThe logic is now as
follows:\nWhen reading a stream, the privileges of the current user are
returned\nalong with the stream itself. These are grouped like
this:\n```\ninterface IngestStreamPrivileges {\n // User can change
everything about the stream\n manage: boolean;\n // User can read stats
(like size in bytes) about the stream\n monitor: boolean;\n // User can
change the retention policy of the stream\n lifecycle: boolean;\n //
User can simulate changes to the processing or the mapping of the
stream\n simulate: boolean;\n}\n```\n\nThis is part of the definition
response and is passed around to the\ncomponents and disabled buttons
and similar in the places where this is\nnecessary.\n\nThe \"advanced\"
tab is only shown when full `manage` permissions are\npresent - there
constellations of permissions that would allow some\naccess but not all
(e.g. having `read_pipelines` but not\n`manage_index_templates`), but
these should be rather rare and not worth\nthe additional effort.\n\n##
Conditions\n\nIn the following places privileges are checked:\n*
Overview\n * Without `monitor`, the overall stats are not shown\n*
Enrichment\n * Without `manage`, you can't save changes\n * Without
`simulate`, the UI is readonly\n* Partitioning\n * Without `manage`, you
can't save changes\n * Without `simulate`, the UI is readonly\n* Schema
editor\n * Without `manage`, the UI is readonly\n* Retention\n * Without
`monitor`, the ingest stats are not shown\n* Without `lifecycle`, the
retention can't be changed and ILM breakdown\nis not rendered\n*
Advanced\n * Without `manage`, the tab is hidden completely\n\n##
Drive-by fix\n\nI noticed that we still register the app header action
menu which adds\nan empty bar on serverless, removed that code.\n\n##
Testing\n\nCheck\nhttps://github.com/elastic/kibana/pull/217353/files#diff-d8f33d7021058bf90cbeea908bf399da2af50d8b8bfac8a07f160ddc0cdff12bR747\nfor
which Elasticsearch level privileges you need for
different\npermutations. Then set up a role and a user and log in as
that user.\n\nAlso test the different pre-defined roles on
serverless.","sha":"fd374463f74caac17b07120c34d2fc6c8e5e2754"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/217353","number":217353,"mergeCommit":{"message":"🌊
Streams: Permission handling (#217353)\n\nCurrently, the streams UI
doesn't deal well with partial permissions.\nThis PR improves that. As a
lot of things come together in streams, we\ncould do even better, but I
think it's OK to draw a line somewhere.\n\nThe logic is now as
follows:\nWhen reading a stream, the privileges of the current user are
returned\nalong with the stream itself. These are grouped like
this:\n```\ninterface IngestStreamPrivileges {\n // User can change
everything about the stream\n manage: boolean;\n // User can read stats
(like size in bytes) about the stream\n monitor: boolean;\n // User can
change the retention policy of the stream\n lifecycle: boolean;\n //
User can simulate changes to the processing or the mapping of the
stream\n simulate: boolean;\n}\n```\n\nThis is part of the definition
response and is passed around to the\ncomponents and disabled buttons
and similar in the places where this is\nnecessary.\n\nThe \"advanced\"
tab is only shown when full `manage` permissions are\npresent - there
constellations of permissions that would allow some\naccess but not all
(e.g. having `read_pipelines` but not\n`manage_index_templates`), but
these should be rather rare and not worth\nthe additional effort.\n\n##
Conditions\n\nIn the following places privileges are checked:\n*
Overview\n * Without `monitor`, the overall stats are not shown\n*
Enrichment\n * Without `manage`, you can't save changes\n * Without
`simulate`, the UI is readonly\n* Partitioning\n * Without `manage`, you
can't save changes\n * Without `simulate`, the UI is readonly\n* Schema
editor\n * Without `manage`, the UI is readonly\n* Retention\n * Without
`monitor`, the ingest stats are not shown\n* Without `lifecycle`, the
retention can't be changed and ILM breakdown\nis not rendered\n*
Advanced\n * Without `manage`, the tab is hidden completely\n\n##
Drive-by fix\n\nI noticed that we still register the app header action
menu which adds\nan empty bar on serverless, removed that code.\n\n##
Testing\n\nCheck\nhttps://github.com/elastic/kibana/pull/217353/files#diff-d8f33d7021058bf90cbeea908bf399da2af50d8b8bfac8a07f160ddc0cdff12bR747\nfor
which Elasticsearch level privileges you need for
different\npermutations. Then set up a role and a user and log in as
that user.\n\nAlso test the different pre-defined roles on
serverless.","sha":"fd374463f74caac17b07120c34d2fc6c8e5e2754"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
This commit is contained in:
Joe Reuter 2025-04-08 17:52:47 +02:00 committed by GitHub
parent 8bdecd6641
commit eba8935713
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 384 additions and 235 deletions

BIN
.swn Normal file

Binary file not shown.

View file

@ -11,4 +11,10 @@ export const ingestStream = {
name: 'logs.nginx',
elasticsearch_assets: [],
stream: ingestStreamConfig,
privileges: {
manage: true,
monitor: true,
lifecycle: true,
simulate: true,
},
};

View file

@ -70,6 +70,17 @@ const ingestUpsertRequestSchema: z.Schema<IngestUpsertRequest> = z.union([
unwiredIngestUpsertRequestSchema,
]);
interface IngestStreamPrivileges {
// User can change everything about the stream
manage: boolean;
// User can read stats (like size in bytes) about the stream
monitor: boolean;
// User can change the retention policy of the stream
lifecycle: boolean;
// User can simulate changes to the processing or the mapping of the stream
simulate: boolean;
}
/**
* Stream get response
*/
@ -77,6 +88,7 @@ interface WiredStreamGetResponse extends StreamGetResponseBase {
stream: WiredStreamDefinition;
inherited_fields: InheritedFieldDefinition;
effective_lifecycle: WiredIngestStreamEffectiveLifecycle;
privileges: IngestStreamPrivileges;
}
interface UnwiredStreamGetResponse extends StreamGetResponseBase {
@ -84,6 +96,7 @@ interface UnwiredStreamGetResponse extends StreamGetResponseBase {
elasticsearch_assets?: ElasticsearchAssets;
data_stream_exists: boolean;
effective_lifecycle: UnwiredIngestStreamEffectiveLifecycle;
privileges: IngestStreamPrivileges;
}
type IngestStreamGetResponse = WiredStreamGetResponse | UnwiredStreamGetResponse;
@ -121,12 +134,20 @@ const ingestStreamUpsertRequestSchema: z.Schema<IngestStreamUpsertRequest> = z.u
unwiredStreamUpsertRequestSchema,
]);
const ingestStreamPrivilegesSchema: z.Schema<IngestStreamPrivileges> = z.object({
manage: z.boolean(),
monitor: z.boolean(),
lifecycle: z.boolean(),
simulate: z.boolean(),
});
const wiredStreamGetResponseSchema: z.Schema<WiredStreamGetResponse> = z.intersection(
streamGetResponseSchemaBase,
z.object({
stream: wiredStreamDefinitionSchema,
inherited_fields: inheritedFieldDefinitionSchema,
effective_lifecycle: wiredIngestStreamEffectiveLifecycleSchema,
privileges: ingestStreamPrivilegesSchema,
})
);
@ -137,6 +158,7 @@ const unwiredStreamGetResponseSchema: z.Schema<UnwiredStreamGetResponse> = z.int
elasticsearch_assets: z.optional(elasticsearchAssetsSchema),
data_stream_exists: z.boolean(),
effective_lifecycle: unwiredIngestStreamEffectiveLifecycleSchema,
privileges: ingestStreamPrivilegesSchema,
})
);

View file

@ -713,6 +713,47 @@ export class StreamsClient {
});
}
/**
* Checks whether the user has the required privileges to manage the stream.
* Managing a stream means updating the stream properties. It does not
* include the dashboard links.
*/
async getPrivileges(name: string) {
const privileges =
await this.dependencies.scopedClusterClient.asCurrentUser.security.hasPrivileges({
cluster: [
'manage_index_templates',
'manage_ingest_pipelines',
'manage_pipeline',
'read_pipeline',
],
index: [
{
names: [name],
privileges: [
'read',
'write',
'create',
'manage',
'monitor',
'manage_data_stream_lifecycle',
'manage_ilm',
],
},
],
});
return {
manage:
Object.values(privileges.cluster).every((privilege) => privilege === true) &&
Object.values(privileges.index[name]).every((privilege) => privilege === true),
monitor: privileges.index[name].monitor,
lifecycle:
privileges.index[name].manage_data_stream_lifecycle && privileges.index[name].manage_ilm,
simulate: privileges.cluster.read_pipeline && privileges.index[name].create,
};
}
/**
* Creates an on-the-fly ingest stream definition
* from a concrete data stream.

View file

@ -49,7 +49,7 @@ export async function readStream({
}
// These queries are only relavant for IngestStreams
const [ancestors, dataStream] = await Promise.all([
const [ancestors, dataStream, privileges] = await Promise.all([
streamsClient.getAncestors(name),
streamsClient.getDataStream(name).catch((e) => {
if (e.statusCode === 404) {
@ -57,17 +57,20 @@ export async function readStream({
}
throw e;
}),
streamsClient.getPrivileges(name),
]);
if (isUnwiredStreamDefinition(streamDefinition)) {
return {
stream: streamDefinition,
elasticsearch_assets: dataStream
? await getUnmanagedElasticsearchAssets({
dataStream,
scopedClusterClient,
})
: undefined,
privileges,
elasticsearch_assets:
dataStream && privileges.manage
? await getUnmanagedElasticsearchAssets({
dataStream,
scopedClusterClient,
})
: undefined,
data_stream_exists: !!dataStream,
effective_lifecycle: getDataStreamLifecycle(dataStream),
dashboards,
@ -78,6 +81,7 @@ export async function readStream({
const body: WiredStreamGetResponse = {
stream: streamDefinition,
dashboards,
privileges,
effective_lifecycle: findInheritedLifecycle(streamDefinition, ancestors),
inherited_fields: getInheritedFieldsFromAncestors(ancestors),
};

View file

@ -13,12 +13,10 @@ import {
RouteRenderer,
RouterProvider,
} from '@kbn/typed-react-router-config';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { StreamsAppContextProvider } from '../streams_app_context_provider';
import { streamsAppRouter } from '../../routes/config';
import { StreamsAppStartDependencies } from '../../types';
import { StreamsAppServices } from '../../services/types';
import { HeaderMenuPortal } from '../header_menu';
import { TimeFilterProvider } from '../../hooks/use_timefilter';
export function AppRoot({
@ -53,28 +51,9 @@ export function AppRoot({
<BreadcrumbsContextProvider>
<RouteRenderer />
</BreadcrumbsContextProvider>
<StreamsAppHeaderActionMenu appMountParameters={appMountParameters} />
</RouterProvider>
</TimeFilterProvider>
</RedirectAppLinks>
</StreamsAppContextProvider>
);
}
export function StreamsAppHeaderActionMenu({
appMountParameters,
}: {
appMountParameters: AppMountParameters;
}) {
const { setHeaderActionMenu, theme$ } = appMountParameters;
return (
<HeaderMenuPortal setHeaderActionMenu={setHeaderActionMenu} theme$={theme$}>
<EuiFlexGroup responsive={false} gutterSize="s">
<EuiFlexItem>
<></>
</EuiFlexItem>
</EuiFlexGroup>
</HeaderMenuPortal>
);
}

View file

@ -6,13 +6,14 @@
*/
import React from 'react';
import { EuiButton, EuiButtonEmpty, EuiFlexGroup } from '@elastic/eui';
import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useDiscardConfirm } from '../../../hooks/use_discard_confirm';
interface ManagementBottomBarProps {
confirmButtonText?: string;
disabled?: boolean;
insufficientPrivileges?: boolean;
isLoading?: boolean;
onCancel: () => void;
onConfirm: () => void;
@ -22,6 +23,7 @@ export function ManagementBottomBar({
confirmButtonText = defaultConfirmButtonText,
disabled = false,
isLoading = false,
insufficientPrivileges = false,
onCancel,
onConfirm,
}: ManagementBottomBarProps) {
@ -46,18 +48,31 @@ export function ManagementBottomBar({
defaultMessage: 'Cancel changes',
})}
</EuiButtonEmpty>
<EuiButton
data-test-subj="streamsAppManagementBottomBarButton"
disabled={disabled}
color="primary"
fill
size="s"
iconType="check"
onClick={onConfirm}
isLoading={isLoading}
<EuiToolTip
content={
insufficientPrivileges
? i18n.translate(
'xpack.streams.streamDetailView.managementTab.bottomBar.onlySimulate',
{
defaultMessage: "You don't have sufficient privileges to save changes.",
}
)
: undefined
}
>
{confirmButtonText}
</EuiButton>
<EuiButton
data-test-subj="streamsAppManagementBottomBarButton"
disabled={disabled || insufficientPrivileges}
color="primary"
fill
size="s"
iconType="check"
onClick={onConfirm}
isLoading={isLoading}
>
{confirmButtonText}
</EuiButton>
</EuiToolTip>
</EuiFlexGroup>
);
}

View file

@ -71,6 +71,9 @@ export function StreamDetailEnrichmentContentImpl() {
const { resetChanges, saveChanges } = useStreamEnrichmentEvents();
const hasChanges = useStreamsEnrichmentSelector((state) => state.can({ type: 'stream.update' }));
const canManage = useStreamsEnrichmentSelector(
(state) => state.context.definition.privileges.manage
);
const isSavingChanges = useStreamsEnrichmentSelector((state) =>
state.matches({ ready: { stream: 'updating' } })
);
@ -124,6 +127,7 @@ export function StreamDetailEnrichmentContentImpl() {
onConfirm={saveChanges}
isLoading={isSavingChanges}
disabled={!hasChanges}
insufficientPrivileges={!canManage}
/>
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
@ -134,6 +138,7 @@ const ProcessorsEditor = React.memo(() => {
const { euiTheme } = useEuiTheme();
const { reorderProcessors } = useStreamEnrichmentEvents();
const definition = useStreamsEnrichmentSelector((state) => state.context.definition);
const processorsRefs = useStreamsEnrichmentSelector((state) =>
state.context.processorsRefs.filter((processorRef) =>
@ -222,6 +227,7 @@ const ProcessorsEditor = React.memo(() => {
<SortableList onDragItem={handlerItemDrag}>
{processorsRefs.map((processorRef, idx) => (
<DraggableProcessorListItem
disableDrag={!definition.privileges.manage}
key={processorRef.id}
idx={idx}
processorRef={processorRef}
@ -230,7 +236,7 @@ const ProcessorsEditor = React.memo(() => {
))}
</SortableList>
)}
<AddProcessorPanel />
{definition.privileges.simulate && <AddProcessorPanel />}
</EuiPanel>
<EuiPanel paddingSize="m" hasShadow={false} grow={false}>
{!isEmpty(errors.ignoredFields) && (

View file

@ -207,6 +207,7 @@ export interface EditProcessorPanelProps {
export function EditProcessorPanel({ processorRef, processorMetrics }: EditProcessorPanelProps) {
const { euiTheme } = useEuiTheme();
const state = useSelector(processorRef, (s) => s);
const canEdit = useStreamsEnrichmentSelector((s) => s.context.definition.privileges.manage);
const previousProcessor = state.context.previousProcessor;
const processor = state.context.processor;
@ -343,6 +344,7 @@ export function EditProcessorPanel({ processorRef, processorMetrics }: EditProce
data-test-subj="streamsAppEditProcessorPanelButton"
onClick={handleOpen}
iconType="pencil"
disabled={!canEdit}
color="text"
size="xs"
aria-label={i18n.translate(

View file

@ -11,13 +11,15 @@ import { EditProcessorPanel, type EditProcessorPanelProps } from './processors';
export const DraggableProcessorListItem = ({
idx,
disableDrag,
...props
}: EditProcessorPanelProps & { idx: number }) => (
}: EditProcessorPanelProps & { idx: number; disableDrag: boolean }) => (
<EuiDraggable
index={idx}
spacing="m"
draggableId={props.processorRef.id}
hasInteractiveChildren
isDragDisabled={disableDrag}
css={{
paddingLeft: 0,
paddingRight: 0,

View file

@ -195,18 +195,20 @@ export function StreamDetailLifecycle({
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={2}>
<EuiPanel grow={true} hasShadow={false} hasBorder paddingSize="s">
<IngestionRate
definition={definition}
refreshStats={refreshStats}
isLoadingStats={isLoadingStats}
stats={stats}
/>
</EuiPanel>
</EuiFlexItem>
{definition.privileges.monitor && (
<EuiFlexItem grow={2}>
<EuiPanel grow={true} hasShadow={false} hasBorder paddingSize="s">
<IngestionRate
definition={definition}
refreshStats={refreshStats}
isLoadingStats={isLoadingStats}
stats={stats}
/>
</EuiPanel>
</EuiFlexItem>
)}
{isIlmLifecycle(definition.effective_lifecycle) ? (
{definition.privileges.lifecycle && isIlmLifecycle(definition.effective_lifecycle) ? (
<EuiFlexItem grow={3}>
<EuiPanel grow={true} hasShadow={false} hasBorder paddingSize="s">
<IlmSummary definition={definition} lifecycle={definition.effective_lifecycle} />

View file

@ -29,6 +29,7 @@ import {
EuiPanel,
EuiPopover,
EuiText,
EuiToolTip,
formatNumber,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -37,6 +38,7 @@ import { IlmLink } from './ilm_link';
import { useStreamsAppRouter } from '../../../hooks/use_streams_app_router';
import { DataStreamStats } from './hooks/use_data_stream_stats';
import { formatIngestionRate } from './helpers/format_bytes';
import { PrivilegesWarningIconWrapper } from '../../insufficient_privileges/insufficient_privileges';
export function RetentionMetadata({
definition,
@ -61,16 +63,30 @@ export function RetentionMetadata({
lifecycleActions.length === 0 ? null : (
<EuiPopover
button={
<EuiButton
data-test-subj="streamsAppRetentionMetadataEditDataRetentionButton"
size="s"
fullWidth
onClick={toggleMenu}
<EuiToolTip
content={
!definition.privileges.lifecycle
? i18n.translate(
'xpack.streams.entityDetailViewWithoutParams.editDataRetention.insufficientPrivileges',
{
defaultMessage: "You don't have sufficient privileges to change retention.",
}
)
: undefined
}
>
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.editDataRetention', {
defaultMessage: 'Edit data retention',
})}
</EuiButton>
<EuiButton
data-test-subj="streamsAppRetentionMetadataEditDataRetentionButton"
size="s"
fullWidth
onClick={toggleMenu}
disabled={!definition.privileges.lifecycle}
>
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.editDataRetention', {
defaultMessage: 'Edit data retention',
})}
</EuiButton>
</EuiToolTip>
}
isOpen={isMenuOpen}
closePopover={closeMenu}
@ -178,15 +194,20 @@ export function RetentionMetadata({
'Approximate average (stream total size divided by the number of days since creation).',
})}
value={
statsError ? (
'-'
) : isLoadingStats || !stats ? (
<EuiLoadingSpinner size="s" />
) : stats.bytesPerDay ? (
formatIngestionRate(stats.bytesPerDay)
) : (
'-'
)
<PrivilegesWarningIconWrapper
hasPrivileges={definition.privileges.monitor}
title="ingestionRate"
>
{statsError ? (
'-'
) : isLoadingStats || !stats ? (
<EuiLoadingSpinner size="s" />
) : stats.bytesPerDay ? (
formatIngestionRate(stats.bytesPerDay)
) : (
'-'
)}
</PrivilegesWarningIconWrapper>
}
/>
<EuiHorizontalRule margin="s" />
@ -195,13 +216,18 @@ export function RetentionMetadata({
defaultMessage: 'Total doc count',
})}
value={
statsError ? (
'-'
) : isLoadingStats || !stats ? (
<EuiLoadingSpinner size="s" />
) : (
formatNumber(stats.totalDocs, '0,0')
)
<PrivilegesWarningIconWrapper
hasPrivileges={definition.privileges.monitor}
title="totalDocCount"
>
{statsError ? (
'-'
) : isLoadingStats || !stats ? (
<EuiLoadingSpinner size="s" />
) : (
formatNumber(stats.totalDocs, '0,0')
)}
</PrivilegesWarningIconWrapper>
}
/>
</EuiPanel>

View file

@ -75,14 +75,19 @@ export function ClassicStreamDetailManagement({
};
}
tabs.advanced = {
content: (
<UnmanagedElasticsearchAssets definition={definition} refreshDefinition={refreshDefinition} />
),
label: i18n.translate('xpack.streams.streamDetailView.advancedTab', {
defaultMessage: 'Advanced',
}),
};
if (definition.privileges.manage) {
tabs.advanced = {
content: (
<UnmanagedElasticsearchAssets
definition={definition}
refreshDefinition={refreshDefinition}
/>
),
label: i18n.translate('xpack.streams.streamDetailView.advancedTab', {
defaultMessage: 'Advanced',
}),
};
}
if (!isValidManagementSubTab(subtab)) {
return (

View file

@ -13,6 +13,7 @@ import {
EuiDroppable,
EuiDraggable,
EuiButton,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/css';
@ -60,26 +61,39 @@ export function ChildStreamList({ availableStreams }: { availableStreams: string
defaultMessage: 'Routing rules',
})}
</EuiText>
<EuiFlexItem grow={false}>
<EuiButton
iconType="plus"
size="s"
data-test-subj="streamsAppStreamDetailRoutingAddRuleButton"
onClick={() => {
selectChildUnderEdit({
isNew: true,
child: {
destination: `${definition.stream.name}.child`,
if: cloneDeep(EMPTY_EQUALS_CONDITION),
},
});
}}
>
{i18n.translate('xpack.streams.streamDetailRouting.addRule', {
defaultMessage: 'Create child stream',
})}
</EuiButton>
</EuiFlexItem>
{(definition.privileges.simulate || definition.privileges.manage) && (
<EuiFlexItem grow={false}>
<EuiToolTip
content={
definition.privileges.simulate
? i18n.translate('xpack.streams.streamDetailRouting.rules.onlySimulate', {
defaultMessage:
"You don't have sufficient privileges to create new streams, only simulate.",
})
: undefined
}
>
<EuiButton
iconType="plus"
size="s"
data-test-subj="streamsAppStreamDetailRoutingAddRuleButton"
onClick={() => {
selectChildUnderEdit({
isNew: true,
child: {
destination: `${definition.stream.name}.child`,
if: cloneDeep(EMPTY_EQUALS_CONDITION),
},
});
}}
>
{i18n.translate('xpack.streams.streamDetailRouting.addRule', {
defaultMessage: 'Create child stream',
})}
</EuiButton>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexGroup
@ -98,6 +112,7 @@ export function ChildStreamList({ availableStreams }: { availableStreams: string
<EuiDraggable
key={child.destination}
index={i}
isDragDisabled={!definition.privileges.manage}
draggableId={child.destination}
hasInteractiveChildren={true}
customDragHandle={true}
@ -111,7 +126,9 @@ export function ChildStreamList({ availableStreams }: { availableStreams: string
>
<RoutingStreamEntry
draggableProvided={provided}
disableEditButton={hasChildStreamsOrderChanged}
disableEditButton={
hasChildStreamsOrderChanged || !definition.privileges.manage
}
child={
!childUnderEdit?.isNew &&
child.destination === childUnderEdit?.child.destination

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiFlexGroup, EuiButton, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui';
import { EuiFlexGroup, EuiButton, EuiFlexItem, EuiButtonEmpty, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { IngestUpsertRequest } from '@kbn/streams-schema';
@ -169,7 +169,7 @@ export function ControlBar() {
<EuiButtonEmpty
color="danger"
size="s"
disabled={routingAppState.saveInProgress}
disabled={routingAppState.saveInProgress || !definition.privileges.manage}
data-test-subj="streamsAppRoutingStreamEntryRemoveButton"
onClick={() => {
routingAppState.setShowDeleteModal(true);
@ -194,19 +194,30 @@ export function ControlBar() {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
<EuiButton
isLoading={routingAppState.saveInProgress}
onClick={saveOrUpdateChildren}
data-test-subj="streamsAppStreamDetailRoutingSaveButton"
<EuiToolTip
content={
!definition.privileges.manage
? i18n.translate('xpack.streams.streamDetailRouting.onlySimulate', {
defaultMessage: "You don't have sufficient privileges to save changes.",
})
: undefined
}
>
{routingAppState.childUnderEdit && routingAppState.childUnderEdit.isNew
? i18n.translate('xpack.streams.streamDetailRouting.add', {
defaultMessage: 'Save',
})
: i18n.translate('xpack.streams.streamDetailRouting.change', {
defaultMessage: 'Change routing',
})}
</EuiButton>
<EuiButton
isLoading={routingAppState.saveInProgress}
disabled={routingAppState.saveInProgress || !definition.privileges.manage}
onClick={saveOrUpdateChildren}
data-test-subj="streamsAppStreamDetailRoutingSaveButton"
>
{routingAppState.childUnderEdit && routingAppState.childUnderEdit.isNew
? i18n.translate('xpack.streams.streamDetailRouting.add', {
defaultMessage: 'Save',
})
: i18n.translate('xpack.streams.streamDetailRouting.change', {
defaultMessage: 'Change routing',
})}
</EuiButton>
</EuiToolTip>
</EuiFlexGroup>
</EuiFlexGroup>
);

View file

@ -34,7 +34,7 @@ export const StreamDetailSchemaEditor = ({ definition, refreshDefinition }: Sche
onRefreshData={refreshFields}
withControls
withFieldSimulation
withTableActions={!isRootStreamDefinition(definition.stream)}
withTableActions={!isRootStreamDefinition(definition.stream) && definition.privileges.manage}
/>
);
};

View file

@ -1,30 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { render } from '@testing-library/react';
import React from 'react';
import HeaderMenuPortal from './header_menu_portal';
import { themeServiceMock } from '@kbn/core/public/mocks';
describe('HeaderMenuPortal', () => {
describe('when unmounted', () => {
it('calls setHeaderActionMenu with undefined', () => {
const setHeaderActionMenu = jest.fn();
const theme$ = themeServiceMock.createTheme$();
const { unmount } = render(
<HeaderMenuPortal setHeaderActionMenu={setHeaderActionMenu} theme$={theme$}>
test
</HeaderMenuPortal>
);
unmount();
expect(setHeaderActionMenu).toHaveBeenCalledWith(undefined);
});
});
});

View file

@ -1,41 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useMemo, ReactNode } from 'react';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
// FIXME use import { toMountPoint } from '@kbn/react-kibana-mount';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { AppMountParameters } from '@kbn/core/public';
export interface HeaderMenuPortalProps {
children: ReactNode;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
theme$: AppMountParameters['theme$'];
}
// eslint-disable-next-line import/no-default-export
export default function HeaderMenuPortal({
children,
setHeaderActionMenu,
theme$,
}: HeaderMenuPortalProps) {
const portalNode = useMemo(() => createHtmlPortalNode(), []);
useEffect(() => {
setHeaderActionMenu((element) => {
const mount = toMountPoint(<OutPortal node={portalNode} />, { theme$ });
return mount(element);
});
return () => {
portalNode.unmount();
setHeaderActionMenu(undefined);
};
}, [portalNode, setHeaderActionMenu, theme$]);
return <InPortal node={portalNode}>{children}</InPortal>;
}

View file

@ -1,20 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { lazy, Suspense } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import type { HeaderMenuPortalProps } from './header_menu_portal';
const HeaderMenuPortalLazy = lazy(() => import('./header_menu_portal'));
export function HeaderMenuPortal(props: HeaderMenuPortalProps) {
return (
<Suspense fallback={<EuiLoadingSpinner />}>
<HeaderMenuPortalLazy {...props} />
</Suspense>
);
}

View file

@ -0,0 +1,82 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import useToggle from 'react-use/lib/useToggle';
import { i18n } from '@kbn/i18n';
import {
EuiButtonIcon,
EuiButtonIconProps,
EuiPopover,
EuiToolTip,
EuiIcon,
EuiFlexGroup,
EuiButtonIconPropsForButton,
} from '@elastic/eui';
const insufficientPrivilegesText = i18n.translate('xpack.streams.insufficientPrivilegesMessage', {
defaultMessage: "You don't have sufficient privileges to access this information.",
});
export const PrivilegesWarningIconWrapper = ({
hasPrivileges,
title,
mode = 'popover',
iconColor = 'warning',
popoverCss,
children,
}: {
hasPrivileges: boolean;
title: string;
mode?: 'tooltip' | 'popover';
iconColor?: EuiButtonIconPropsForButton['color'];
popoverCss?: EuiButtonIconProps['css'];
children: React.ReactNode;
}) => {
const [isPopoverOpen, togglePopover] = useToggle(false);
const handleButtonClick = togglePopover;
if (hasPrivileges) {
return <>{children}</>;
}
return mode === 'popover' ? (
<EuiPopover
css={popoverCss}
attachToAnchor={true}
anchorPosition="downCenter"
button={
<EuiButtonIcon
data-test-subj={`streamsInsufficientPrivileges-${title}`}
aria-label={insufficientPrivilegesText}
title={insufficientPrivilegesText}
iconType="warning"
color={iconColor}
onClick={handleButtonClick}
/>
}
isOpen={isPopoverOpen}
closePopover={togglePopover}
>
{insufficientPrivilegesText}
</EuiPopover>
) : (
<EuiToolTip content={insufficientPrivilegesText}>
<EuiFlexGroup alignItems="center">
<EuiIcon
data-test-subj={`streamsInsufficientPrivileges-${title}`}
aria-label={insufficientPrivilegesText}
title={insufficientPrivilegesText}
type="warning"
color={iconColor}
/>
{children}
</EuiFlexGroup>
</EuiToolTip>
);
};

View file

@ -50,23 +50,25 @@ export function ChildStreamList({ definition }: { definition?: IngestStreamGetRe
'Create sub streams to split out data with different retention policies, schemas, and more.',
})}
</EuiText>
<EuiFlexGroup justifyContent="center">
<EuiButton
data-test-subj="streamsAppChildStreamListCreateChildStreamButton"
iconType="plusInCircle"
href={router.link('/{key}/{tab}/{subtab}', {
path: {
key: definition.stream.name,
tab: 'management',
subtab: 'route',
},
})}
>
{i18n.translate('xpack.streams.entityDetailOverview.createChildStream', {
defaultMessage: 'Create child stream',
})}
</EuiButton>
</EuiFlexGroup>
{definition.privileges.manage && (
<EuiFlexGroup justifyContent="center">
<EuiButton
data-test-subj="streamsAppChildStreamListCreateChildStreamButton"
iconType="plusInCircle"
href={router.link('/{key}/{tab}/{subtab}', {
path: {
key: definition.stream.name,
tab: 'management',
subtab: 'route',
},
})}
>
{i18n.translate('xpack.streams.entityDetailOverview.createChildStream', {
defaultMessage: 'Create child stream',
})}
</EuiButton>
</EuiFlexGroup>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -24,6 +24,7 @@ import {
formatIngestionRate,
} from '../../data_management/stream_detail_lifecycle/helpers/format_bytes';
import { useDataStreamStats } from '../../data_management/stream_detail_lifecycle/hooks/use_data_stream_stats';
import { PrivilegesWarningIconWrapper } from '../../insufficient_privileges/insufficient_privileges';
interface StreamStatsPanelProps {
definition: IngestStreamGetResponse;
@ -123,15 +124,25 @@ export function StreamStatsPanel({ definition }: StreamStatsPanelProps) {
<StatItem
label={documentCountLabel}
value={
dataStreamStats ? formatNumber(dataStreamStats.totalDocs || 0, 'decimal0') : '-'
<PrivilegesWarningIconWrapper
hasPrivileges={definition.privileges.monitor}
title="totalDocCount"
>
{dataStreamStats ? formatNumber(dataStreamStats.totalDocs || 0, 'decimal0') : '-'}
</PrivilegesWarningIconWrapper>
}
/>
<StatItem
label={storageSizeLabel}
value={
dataStreamStats && dataStreamStats.sizeBytes
? formatBytes(dataStreamStats.sizeBytes)
: '-'
<PrivilegesWarningIconWrapper
hasPrivileges={definition.privileges.monitor}
title="sizeBytes"
>
{dataStreamStats && dataStreamStats.sizeBytes
? formatBytes(dataStreamStats.sizeBytes)
: '-'}
</PrivilegesWarningIconWrapper>
}
withBorder
/>
@ -152,7 +163,14 @@ export function StreamStatsPanel({ definition }: StreamStatsPanelProps) {
</>
}
value={
dataStreamStats ? formatIngestionRate(dataStreamStats.bytesPerDay || 0, true) : '-'
<PrivilegesWarningIconWrapper
hasPrivileges={definition.privileges.monitor}
title="ingestionRate"
>
{dataStreamStats
? formatIngestionRate(dataStreamStats.bytesPerDay || 0, true)
: '-'}
</PrivilegesWarningIconWrapper>
}
withBorder
/>