[APM] Add support for read-only (viewer) user (#161840)

Currently it is not possible for `viewer` or `editor` roles (the
recommended stack roles) to access the diagnostics tool. This PR catches
any permission exception gracefully. User with limited access will
thereby still be able to use the diagnostics tool for the areas where
they have access.
This commit is contained in:
Søren Louv-Jansen 2023-07-18 16:09:37 +02:00 committed by GitHub
parent e8fefc6304
commit 52d34d4be7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 749 additions and 291 deletions

View file

@ -1,13 +1,46 @@
{
"created_at": "2023-06-22T12:16:54.010Z",
"diagnosticsPrivileges": {
"index": {
"apm-*": {
"read": true
},
"logs-apm*": {
"read": true
},
"metrics-apm*": {
"read": true
},
"remote_cluster:apm-*": {
"read": true
},
"remote_cluster:logs-apm*": {
"read": true
},
"remote_cluster:metrics-apm*": {
"read": true
},
"remote_cluster:traces-apm*": {
"read": true
},
"traces-apm*": {
"read": true
}
},
"cluster": {
"read_pipeline": true,
"manage_index_templates": true,
"monitor": true
},
"hasAllPrivileges": true
},
"elasticsearchVersion": "8.8.0",
"esResponses": {
"fieldCaps": {
"fields": {
"service.name": {
"keyword": {
"type": "keyword",
"metadata_field": false,
"type": "keyword", "metadata_field": false,
"searchable": true,
"aggregatable": true
}

View file

@ -6,119 +6,162 @@
*/
describe('Diagnostics', () => {
describe('when no data is loaded', () => {
describe('when logging in as superuser', () => {
beforeEach(() => {
cy.loginAs({ username: 'elastic', password: 'changeme' });
cy.loginAsSuperUser();
});
it('can display summary tab', () => {
cy.visitKibana('/app/apm/diagnostics');
describe('when no data is loaded', () => {
it('can display summary tab for superuser', () => {
cy.visitKibana('/app/apm/diagnostics');
// integration package
cy.get('[data-test-subj="integrationPackageStatus_Badge"]').should(
'have.text',
'OK'
);
// integration package
cy.get('[data-test-subj="integrationPackageStatus_Badge"]').should(
'have.text',
'OK'
);
// data stream
cy.get('[data-test-subj="dataStreamsStatus_Badge"]').should(
'have.text',
'OK'
);
// data stream
cy.get('[data-test-subj="dataStreamsStatus_Badge"]').should(
'have.text',
'OK'
);
// Index template
cy.get('[data-test-subj="indexTemplatesStatus_Badge"]').should(
'have.text',
'OK'
);
// Index template
cy.get('[data-test-subj="indexTemplatesStatus_Badge"]').should(
'have.text',
'OK'
);
// Index template
cy.get('[data-test-subj="fieldMappingStatus_Badge"]').should(
'have.text',
'OK'
);
// Index template
cy.get('[data-test-subj="fieldMappingStatus_Badge"]').should(
'have.text',
'OK'
);
});
});
describe('when importing a file', () => {
it('shows the remove button', () => {
importBundle();
cy.get('[data-test-subj="apmImportCardRemoveReportButton"]').should(
'exist'
);
clearBundle();
cy.get('[data-test-subj="apmImportCardRemoveReportButton"]').should(
'not.exist'
);
});
it('can display summary tab', () => {
importBundle();
cy.get('[data-test-subj="summary-tab"]').click();
// integration package
cy.get('[data-test-subj="integrationPackageStatus_Badge"]').should(
'have.text',
'OK'
);
cy.get('[data-test-subj="integrationPackageStatus_Content"]').should(
'have.text',
'APM integration (8.8.0)'
);
// data stream
cy.get('[data-test-subj="dataStreamsStatus_Badge"]').should(
'have.text',
'OK'
);
// Index template
cy.get('[data-test-subj="indexTemplatesStatus_Badge"]').should(
'have.text',
'OK'
);
// Index template
cy.get('[data-test-subj="fieldMappingStatus_Badge"]').should(
'have.text',
'Warning'
);
});
it('can display index template tab', () => {
importBundle();
cy.get('[data-test-subj="index-templates-tab"]').click();
cy.get('.euiTableRow').should('have.length', 19);
});
it('can display data streams tab', () => {
importBundle();
cy.get('[data-test-subj="data-streams-tab"]').click();
cy.get('.euiTableRow').should('have.length', 8);
});
it('can display indices tab', () => {
importBundle();
cy.get('[data-test-subj="indices-tab"]').click();
cy.get('[data-test-subj="indicedWithProblems"] .euiTableRow').should(
'have.length',
138
);
cy.get('[data-test-subj="indicedWithoutProblems"] .euiTableRow').should(
'have.length',
27
);
});
});
});
describe('when importing a file', () => {
describe('when logging in as "viewer" user', () => {
beforeEach(() => {
cy.loginAs({ username: 'elastic', password: 'changeme' });
cy.visitKibana('/app/apm/diagnostics/import-export');
cy.get('#file-picker').selectFile(
'./cypress/e2e/power_user/diagnostics/apm-diagnostics-8.8.0-1687436214804.json'
);
cy.loginAsViewerUser();
});
it('shows the remove button', () => {
cy.get('[data-test-subj="apmImportCardRemoveReportButton"]').should(
'exist'
);
describe('when no data is loaded', () => {
it('displays a warning on "summary" tab about missing privileges ', () => {
cy.visitKibana('/app/apm/diagnostics');
cy.get('.euiPanel > .euiText').should(
'contain.text',
'Not all features are available due to missing privileges.'
);
});
it('hides the tabs that require cluster privileges', () => {
cy.visitKibana('/app/apm/diagnostics');
const tabs = ['Summary', 'Documents', 'Import/Export'];
cy.get(
'[data-test-subj="apmDiagnosticsTemplate"] .euiTabs .euiTab'
).each((tab, i) => cy.wrap(tab).should('have.text', tabs[i]));
});
});
it('can display summary tab', () => {
cy.get('[data-test-subj="summary-tab"]').click();
describe('when importing a file', () => {
it('displays documents tab for the imported bundle', () => {
importBundle();
cy.get('[data-test-subj="documents-tab"]').click();
// integration package
cy.get('[data-test-subj="integrationPackageStatus_Badge"]').should(
'have.text',
'OK'
);
cy.get('[data-test-subj="integrationPackageStatus_Content"]').should(
'have.text',
'APM integration (8.8.0)'
);
// data stream
cy.get('[data-test-subj="dataStreamsStatus_Badge"]').should(
'have.text',
'OK'
);
// Index template
cy.get('[data-test-subj="indexTemplatesStatus_Badge"]').should(
'have.text',
'OK'
);
// Index template
cy.get('[data-test-subj="fieldMappingStatus_Badge"]').should(
'have.text',
'Warning'
);
});
it('can display index template tab', () => {
cy.get('[data-test-subj="index-templates-tab"]').click();
cy.get('.euiTableRow').should('have.length', 19);
});
it('can display data streams tab', () => {
cy.get('[data-test-subj="data-streams-tab"]').click();
cy.get('.euiTableRow').should('have.length', 8);
});
it('can display indices tab', () => {
cy.get('[data-test-subj="indices-tab"]').click();
cy.get('[data-test-subj="indicedWithProblems"] .euiTableRow').should(
'have.length',
138
);
cy.get('[data-test-subj="indicedWithoutProblems"] .euiTableRow').should(
'have.length',
27
);
});
it('can display documents tab', () => {
cy.get('[data-test-subj="documents-tab"]').click();
cy.get('[data-test-subj="documents-table"] .euiTableRow').should(
'have.length',
10
);
cy.get('[data-test-subj="documents-table"] .euiTableRow').should(
'have.length',
10
);
});
});
});
});
function importBundle() {
cy.visitKibana('/app/apm/diagnostics/import-export');
cy.get('#file-picker').selectFile(
'./cypress/e2e/power_user/diagnostics/apm-diagnostics-8.8.0-1687436214804.json'
);
}
function clearBundle() {
cy.get('[data-test-subj="apmTemplateDescriptionClearBundleButton"]').click();
}

View file

@ -11,6 +11,10 @@ import moment from 'moment';
import { AXE_CONFIG, AXE_OPTIONS } from '@kbn/axe-config';
import { ApmUsername } from '../../../server/test_helpers/create_apm_users/authentication';
Cypress.Commands.add('loginAsSuperUser', () => {
return cy.loginAs({ username: 'elastic', password: 'changeme' });
});
Cypress.Commands.add('loginAsViewerUser', () => {
return cy.loginAs({ username: ApmUsername.viewerUser, password: 'changeme' });
});

View file

@ -7,6 +7,7 @@
declare namespace Cypress {
interface Chainable {
loginAsSuperUser(): Cypress.Chainable<Cypress.Response<any>>;
loginAsViewerUser(): Cypress.Chainable<Cypress.Response<any>>;
loginAsEditorUser(): Cypress.Chainable<Cypress.Response<any>>;
loginAsMonitorUser(): Cypress.Chainable<Cypress.Response<any>>;

View file

@ -18,9 +18,9 @@ import {
} from '@elastic/eui';
import { APIReturnType } from '../../../services/rest/create_call_apm_api';
import { useDiagnosticsContext } from './context/use_diagnostics';
import { getIndexTemplateStatus } from './summary_tab/index_templates_status';
import { getIndicesTabStatus } from './summary_tab/indicies_status';
import { getDataStreamTabStatus } from './summary_tab/data_streams_status';
import { getIsIndexTemplateOk } from './summary_tab/index_templates_status';
import { getIsIndicesTabOk } from './summary_tab/indicies_status';
import { getIsDataStreamTabOk } from './summary_tab/data_streams_status';
type DiagnosticsBundle = APIReturnType<'GET /internal/apm/diagnostics'>;
@ -82,7 +82,12 @@ function ExportCard() {
function ImportCard() {
const { setImportedDiagnosticsBundle, isImported } = useDiagnosticsContext();
const [importError, setImportError] = useState(false);
const [importStatus, setImportStatus] = useState<{
isValid: boolean;
errorMessage?: string;
}>({
isValid: true,
});
return (
<EuiCard
icon={<EuiIcon size="xxl" type="exportAction" />}
@ -104,10 +109,11 @@ function ImportCard() {
</EuiButton>
) : (
<>
{importError && (
{!importStatus.isValid && (
<>
<EuiCallOut color="danger" iconType="warning">
The uploaded file could not be parsed
The uploaded file could not be parsed:{' '}
{importStatus.errorMessage}
</EuiCallOut>
<EuiSpacer />
</>
@ -117,7 +123,7 @@ function ImportCard() {
id="file-picker"
multiple
onChange={(_files) => {
setImportError(false);
setImportStatus({ isValid: true });
if (_files && _files.length > 0) {
const file = Array.from(_files)[0];
@ -129,13 +135,14 @@ function ImportCard() {
evt?.target?.result
) as DiagnosticsBundle;
if (isBundleValid(diagnosticsBundle)) {
setImportedDiagnosticsBundle(diagnosticsBundle);
} else {
setImportError(true);
}
validateBundle(diagnosticsBundle);
setImportedDiagnosticsBundle(diagnosticsBundle);
} catch (e) {
setImportError(true);
setImportStatus({
isValid: false,
errorMessage: e.message,
});
console.error(
`Could not parse file ${file.name}. ${e.message}`
);
@ -154,13 +161,13 @@ function ImportCard() {
);
}
function isBundleValid(diagnosticsBundle: DiagnosticsBundle) {
function validateBundle(diagnosticsBundle: DiagnosticsBundle) {
try {
getIndexTemplateStatus(diagnosticsBundle);
getIndicesTabStatus(diagnosticsBundle);
getDataStreamTabStatus(diagnosticsBundle);
return true;
getIsIndexTemplateOk(diagnosticsBundle);
getIsIndicesTabOk(diagnosticsBundle);
getIsDataStreamTabOk(diagnosticsBundle);
} catch (e) {
return false;
console.error('Error parsing uploaded bundle', e);
throw e;
}
}

View file

@ -5,30 +5,37 @@
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { Outlet } from '@kbn/typed-react-router-config';
import React from 'react';
import * as t from 'io-ts';
import { EuiButton, EuiCallOut, EuiIcon } from '@elastic/eui';
import {
EuiButton,
EuiCallOut,
EuiIcon,
EuiLoadingLogo,
EuiEmptyPrompt,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useApmParams } from '../../../hooks/use_apm_params';
import { useApmRouter } from '../../../hooks/use_apm_router';
import { useApmRoutePath } from '../../../hooks/use_apm_route_path';
import { DiagnosticsSummary } from './summary_tab';
import { DiagnosticsSummary, getIsCrossCluster } from './summary_tab';
import { ApmMainTemplate } from '../../routing/templates/apm_main_template';
import { DiagnosticsIndexTemplates } from './index_templates_tab';
import { DiagnosticsIndices } from './indices_tab';
import { DiagnosticsDataStreams } from './data_stream_tab';
import {
DiagnosticsIndexPatternSettings,
getIndexPatternTabStatus,
getIsIndexPatternTabOk,
} from './index_pattern_settings_tab';
import { DiagnosticsImportExport } from './import_export_tab';
import { DiagnosticsContextProvider } from './context/diagnostics_context';
import { useDiagnosticsContext } from './context/use_diagnostics';
import { getIndexTemplateStatus } from './summary_tab/index_templates_status';
import { getDataStreamTabStatus } from './summary_tab/data_streams_status';
import { getIndicesTabStatus } from './summary_tab/indicies_status';
import { getIsIndexTemplateOk } from './summary_tab/index_templates_status';
import { getIsDataStreamTabOk } from './summary_tab/data_streams_status';
import { getIsIndicesTabOk } from './summary_tab/indicies_status';
import { DiagnosticsApmDocuments } from './apm_documents_tab';
import { isPending } from '../../../hooks/use_fetcher';
const params = t.type({
query: t.intersection([
@ -90,11 +97,110 @@ export const diagnosticsRoute = {
function DiagnosticsTemplate({ children }: { children: React.ReactChild }) {
const routePath = useApmRoutePath();
const router = useApmRouter();
const { diagnosticsBundle } = useDiagnosticsContext();
const { diagnosticsBundle, status } = useDiagnosticsContext();
const { query } = useApmParams('/diagnostics/*');
const isCrossCluster = getIsCrossCluster(diagnosticsBundle);
const isLoading = isPending(status);
if (isLoading) {
return (
<EuiEmptyPrompt
icon={<EuiLoadingLogo logo="logoObservability" size="xl" />}
title={
<h2>
{i18n.translate('xpack.apm.diagnostics.loading', {
defaultMessage: 'Loading diagnostics',
})}
</h2>
}
/>
);
}
const hasAllClusterPrivileges =
diagnosticsBundle?.diagnosticsPrivileges.hasAllClusterPrivileges ?? true;
const tabs = [
{
'data-test-subj': 'summary-tab',
href: router.link('/diagnostics', { query }),
label: i18n.translate('xpack.apm.diagnostics.tab.summary', {
defaultMessage: 'Summary',
}),
isSelected: routePath === '/diagnostics',
},
{
'data-test-subj': 'index-pattern-tab',
prepend: !getIsIndexPatternTabOk(diagnosticsBundle) && (
<EuiIcon type="warning" color="red" />
),
href: router.link('/diagnostics/index-pattern-settings', { query }),
label: i18n.translate(
'xpack.apm.diagnostics.tab.index_pattern_settings',
{
defaultMessage: 'Index pattern settings',
}
),
isSelected: routePath === '/diagnostics/index-pattern-settings',
isHidden: isCrossCluster || !hasAllClusterPrivileges,
},
{
'data-test-subj': 'index-templates-tab',
prepend: !getIsIndexTemplateOk(diagnosticsBundle) && (
<EuiIcon type="warning" color="red" />
),
href: router.link('/diagnostics/index-templates', { query }),
label: i18n.translate('xpack.apm.diagnostics.tab.index_templates', {
defaultMessage: 'Index templates',
}),
isSelected: routePath === '/diagnostics/index-templates',
isHidden: isCrossCluster || !hasAllClusterPrivileges,
},
{
'data-test-subj': 'data-streams-tab',
prepend: !getIsDataStreamTabOk(diagnosticsBundle) && (
<EuiIcon type="warning" color="red" />
),
href: router.link('/diagnostics/data-streams', { query }),
label: i18n.translate('xpack.apm.diagnostics.tab.datastreams', {
defaultMessage: 'Data streams',
}),
isSelected: routePath === '/diagnostics/data-streams',
isHidden: isCrossCluster || !hasAllClusterPrivileges,
},
{
'data-test-subj': 'indices-tab',
prepend: !getIsIndicesTabOk(diagnosticsBundle) && (
<EuiIcon type="warning" color="red" />
),
href: router.link('/diagnostics/indices', { query }),
label: i18n.translate('xpack.apm.diagnostics.tab.indices', {
defaultMessage: 'Indices',
}),
isSelected: routePath === '/diagnostics/indices',
isHidden: isCrossCluster || !hasAllClusterPrivileges,
},
{
'data-test-subj': 'documents-tab',
href: router.link('/diagnostics/documents', { query }),
label: i18n.translate('xpack.apm.diagnostics.tab.apmEvents', {
defaultMessage: 'Documents',
}),
isSelected: routePath === '/diagnostics/documents',
},
{
'data-test-subj': 'import-export-tab',
href: router.link('/diagnostics/import-export', { query }),
label: i18n.translate('xpack.apm.diagnostics.tab.import_export', {
defaultMessage: 'Import/Export',
}),
isSelected: routePath === '/diagnostics/import-export',
},
].filter((tab) => !tab.isHidden);
return (
<ApmMainTemplate
data-test-subj="apmDiagnosticsTemplate"
pageTitle="Diagnostics"
environmentFilter={false}
showServiceGroupSaveButton={false}
@ -103,79 +209,7 @@ function DiagnosticsTemplate({ children }: { children: React.ReactChild }) {
iconType: 'magnifyWithExclamation',
rightSideItems: [<RefreshButton />],
description: <TemplateDescription />,
tabs: [
{
'data-test-subj': 'summary-tab',
href: router.link('/diagnostics', { query }),
label: i18n.translate('xpack.apm.diagnostics.tab.summary', {
defaultMessage: 'Summary',
}),
isSelected: routePath === '/diagnostics',
},
{
'data-test-subj': 'index-pattern-tab',
prepend: !getIndexPatternTabStatus(diagnosticsBundle) && (
<EuiIcon type="warning" color="red" />
),
href: router.link('/diagnostics/index-pattern-settings', { query }),
label: i18n.translate(
'xpack.apm.diagnostics.tab.index_pattern_settings',
{
defaultMessage: 'Index pattern settings',
}
),
isSelected: routePath === '/diagnostics/index-pattern-settings',
},
{
'data-test-subj': 'index-templates-tab',
prepend: !getIndexTemplateStatus(diagnosticsBundle) && (
<EuiIcon type="warning" color="red" />
),
href: router.link('/diagnostics/index-templates', { query }),
label: i18n.translate('xpack.apm.diagnostics.tab.index_templates', {
defaultMessage: 'Index templates',
}),
isSelected: routePath === '/diagnostics/index-templates',
},
{
'data-test-subj': 'data-streams-tab',
prepend: !getDataStreamTabStatus(diagnosticsBundle) && (
<EuiIcon type="warning" color="red" />
),
href: router.link('/diagnostics/data-streams', { query }),
label: i18n.translate('xpack.apm.diagnostics.tab.datastreams', {
defaultMessage: 'Data streams',
}),
isSelected: routePath === '/diagnostics/data-streams',
},
{
'data-test-subj': 'indices-tab',
prepend: !getIndicesTabStatus(diagnosticsBundle) && (
<EuiIcon type="warning" color="red" />
),
href: router.link('/diagnostics/indices', { query }),
label: i18n.translate('xpack.apm.diagnostics.tab.indices', {
defaultMessage: 'Indices',
}),
isSelected: routePath === '/diagnostics/indices',
},
{
'data-test-subj': 'documents-tab',
href: router.link('/diagnostics/documents', { query }),
label: i18n.translate('xpack.apm.diagnostics.tab.apmEvents', {
defaultMessage: 'Documents',
}),
isSelected: routePath === '/diagnostics/documents',
},
{
'data-test-subj': 'import-export-tab',
href: router.link('/diagnostics/import-export', { query }),
label: i18n.translate('xpack.apm.diagnostics.tab.import_export', {
defaultMessage: 'Import/Export',
}),
isSelected: routePath === '/diagnostics/import-export',
},
],
tabs,
}}
>
{children}
@ -184,13 +218,20 @@ function DiagnosticsTemplate({ children }: { children: React.ReactChild }) {
}
function TemplateDescription() {
const { isImported } = useDiagnosticsContext();
const { isImported, setImportedDiagnosticsBundle } = useDiagnosticsContext();
if (isImported) {
return (
<EuiCallOut
title="Displaying results from the uploaded diagnostics report"
iconType="exportAction"
/>
>
<EuiButton
data-test-subj="apmTemplateDescriptionClearBundleButton"
onClick={() => setImportedDiagnosticsBundle(undefined)}
>
Clear bundle
</EuiButton>
</EuiCallOut>
);
}

View file

@ -102,9 +102,7 @@ export function DiagnosticsIndexPatternSettings() {
);
}
export function getIndexPatternTabStatus(
diagnosticsBundle?: DiagnosticsBundle
) {
export function getIsIndexPatternTabOk(diagnosticsBundle?: DiagnosticsBundle) {
if (!diagnosticsBundle) {
return true;
}

View file

@ -22,12 +22,12 @@ export function DataStreamsStatus() {
const router = useApmRouter();
const { query } = useApmParams('/diagnostics/*');
const isLoading = status === FETCH_STATUS.LOADING;
const tabStatus = getDataStreamTabStatus(diagnosticsBundle);
const isOk = getIsDataStreamTabOk(diagnosticsBundle);
return (
<TabStatus
isLoading={isLoading}
isOk={tabStatus}
isOk={isOk}
data-test-subj="dataStreamsStatus"
>
Data streams
@ -41,9 +41,9 @@ export function DataStreamsStatus() {
);
}
export function getDataStreamTabStatus(diagnosticsBundle?: DiagnosticsBundle) {
export function getIsDataStreamTabOk(diagnosticsBundle?: DiagnosticsBundle) {
if (!diagnosticsBundle) {
return false;
return true;
}
return diagnosticsBundle.dataStreams.every((ds) => {

View file

@ -6,27 +6,41 @@
*/
import React from 'react';
import { EuiFlexGroup, EuiCallOut } from '@elastic/eui';
import {
EuiFlexGroup,
EuiCallOut,
EuiDescriptionList,
EuiSpacer,
} from '@elastic/eui';
import { APIReturnType } from '../../../../services/rest/create_call_apm_api';
import { ApmIntegrationPackageStatus } from './apm_integration_package_status';
import { IndexTemplatesStatus } from './index_templates_status';
import { FieldMappingStatus } from './indicies_status';
import { DataStreamsStatus } from './data_streams_status';
import { useDiagnosticsContext } from '../context/use_diagnostics';
type DiagnosticsBundle = APIReturnType<'GET /internal/apm/diagnostics'>;
export function DiagnosticsSummary() {
const { diagnosticsBundle } = useDiagnosticsContext();
const isCrossCluster = getIsCrossCluster(diagnosticsBundle);
const hasAllPrivileges =
diagnosticsBundle?.diagnosticsPrivileges.hasAllPrivileges ?? true;
const isCrossCluster = Object.values(
diagnosticsBundle?.apmIndices ?? {}
).some((indicies) => indicies.includes(':'));
if (isCrossCluster) {
if (isCrossCluster || !hasAllPrivileges) {
return (
<EuiCallOut title="Cross cluster search not supported" color="warning">
The APM index settings is targetting remote clusters. Please note: this
is not currently supported by the Diagnostics Tool and functionality
will therefore be limited.
</EuiCallOut>
<>
{isCrossCluster && (
<>
<CrossClusterSearchCallout />
<EuiSpacer />
</>
)}
{diagnosticsBundle && !hasAllPrivileges && (
<PrivilegesCallout diagnosticsBundle={diagnosticsBundle} />
)}
</>
);
}
@ -39,3 +53,68 @@ export function DiagnosticsSummary() {
</EuiFlexGroup>
);
}
function CrossClusterSearchCallout() {
return (
<EuiCallOut title="Cross cluster search not supported" color="warning">
The APM index settings is targetting remote clusters. Please note that
this is not currently supported by the Diagnostics Tool and functionality
will therefore be limited.
</EuiCallOut>
);
}
function PrivilegesCallout({
diagnosticsBundle,
}: {
diagnosticsBundle: DiagnosticsBundle;
}) {
const missingClusterPrivileges = Object.entries(
diagnosticsBundle.diagnosticsPrivileges.cluster
)
.filter(([privilegeName, hasPrivilege]) => !hasPrivilege)
.map(([privilegeName]) => privilegeName);
const missingIndexPrivileges = Object.entries(
diagnosticsBundle.diagnosticsPrivileges.index
)
.filter(([indexName, privObject]) => !privObject.read)
.map(([indexName, privObject]) => indexName);
return (
<>
<EuiCallOut title="Insufficient access" color="warning">
Not all features are available due to missing privileges.
<br />
<br />
<EuiDescriptionList
listItems={[
...(missingClusterPrivileges.length > 0
? [
{
title: 'Missing cluster privileges',
description: missingClusterPrivileges.join(', '),
},
]
: []),
...(missingIndexPrivileges.length > 0
? [
{
title: 'Missing index privileges',
description: missingIndexPrivileges.join(', '),
},
]
: []),
]}
/>
</EuiCallOut>
</>
);
}
export function getIsCrossCluster(diagnosticsBundle?: DiagnosticsBundle) {
return Object.values(diagnosticsBundle?.apmIndices ?? {}).some((indicies) =>
indicies.includes(':')
);
}

View file

@ -20,7 +20,7 @@ export function IndexTemplatesStatus() {
const { query } = useApmParams('/diagnostics/*');
const { diagnosticsBundle, status } = useDiagnosticsContext();
const isLoading = status === FETCH_STATUS.LOADING;
const tabStatus = getIndexTemplateStatus(diagnosticsBundle);
const tabStatus = getIsIndexTemplateOk(diagnosticsBundle);
return (
<TabStatus
@ -39,14 +39,18 @@ export function IndexTemplatesStatus() {
);
}
export function getIndexTemplateStatus(diagnosticsBundle?: DiagnosticsBundle) {
export function getIsIndexTemplateOk(diagnosticsBundle?: DiagnosticsBundle) {
if (!diagnosticsBundle) {
return true;
}
const hasNonStandardIndexTemplates =
diagnosticsBundle?.apmIndexTemplates?.some(
diagnosticsBundle.apmIndexTemplates?.some(
({ isNonStandard }) => isNonStandard
);
const isEveryExpectedApmIndexTemplateInstalled =
diagnosticsBundle?.apmIndexTemplates.every(
diagnosticsBundle.apmIndexTemplates.every(
({ exists, isNonStandard }) => isNonStandard || exists
);

View file

@ -21,7 +21,7 @@ export function FieldMappingStatus() {
const { query } = useApmParams('/diagnostics/*');
const { diagnosticsBundle, status } = useDiagnosticsContext();
const isLoading = status === FETCH_STATUS.LOADING;
const isOk = getIndicesTabStatus(diagnosticsBundle);
const isOk = getIsIndicesTabOk(diagnosticsBundle);
return (
<TabStatus
@ -40,6 +40,10 @@ export function FieldMappingStatus() {
);
}
export function getIndicesTabStatus(diagnosticsBundle?: DiagnosticsBundle) {
return diagnosticsBundle?.invalidIndices.length === 0;
export function getIsIndicesTabOk(diagnosticsBundle?: DiagnosticsBundle) {
if (!diagnosticsBundle) {
return true;
}
return diagnosticsBundle.invalidIndices.length === 0;
}

View file

@ -8,6 +8,7 @@
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { IndicesSimulateTemplateResponse } from '@elastic/elasticsearch/lib/api/types';
import { orderBy } from 'lodash';
import { errors } from '@elastic/elasticsearch';
import { ApmIndicesConfig } from '../../settings/apm_indices/get_apm_indices';
import { getApmIndexPatterns } from './get_indices';
import { getIndexTemplate } from './get_index_template';
@ -27,16 +28,13 @@ export async function getIndexTemplatesByIndexPattern({
apmIndices.transaction,
]);
try {
return await Promise.all(
return await handleInvalidIndexTemplateException(
Promise.all(
indexPatterns.map(async (indexPattern) =>
getSimulatedIndexTemplateForIndexPattern({ indexPattern, esClient })
)
);
} catch (e) {
console.error(e);
return [];
}
)
);
}
async function getSimulatedIndexTemplateForIndexPattern({
@ -95,3 +93,23 @@ function getIsNonStandardIndexTemplate(templateName: string) {
return isNonStandard;
}
async function handleInvalidIndexTemplateException<T>(promise: Promise<T>) {
try {
return await promise;
} catch (error) {
if (
error instanceof errors.ResponseError &&
error.meta.statusCode === 400 &&
// @ts-expect-error
error.meta.body.error.type === 'invalid_index_template_exception'
) {
console.error(
`Suppressed exception caused by cross cluster search: ${error.message}}`
);
return [];
}
throw error;
}
}

View file

@ -5,29 +5,33 @@
* 2.0.
*/
import {
FieldCapsResponse,
IndicesGetResponse,
IngestGetPipelineResponse,
} from '@elastic/elasticsearch/lib/api/types';
import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { ApmIndicesConfig } from '@kbn/observability-plugin/common/typings';
import { SERVICE_NAME } from '../../../../common/es_fields/apm';
import { getApmIndexTemplateNames } from '../helpers/get_apm_index_template_names';
import { getFieldCaps } from './get_field_caps';
import { getIndicesAndIngestPipelines } from './get_indices';
export function getIndicesStates({
indices,
fieldCaps,
ingestPipelines,
export async function getIndicesStates({
esClient,
apmIndices,
}: {
indices: IndicesGetResponse;
fieldCaps: FieldCapsResponse;
ingestPipelines: IngestGetPipelineResponse;
esClient: ElasticsearchClient;
apmIndices: ApmIndicesConfig;
}) {
const { indices, ingestPipelines } = await getIndicesAndIngestPipelines({
esClient,
apmIndices,
});
const indicesWithPipelineId = Object.entries(indices).map(([key, value]) => ({
index: key,
dataStream: value.data_stream,
pipelineId: value.settings?.index?.default_pipeline,
}));
const fieldCaps = await getFieldCaps({ esClient, apmIndices });
const invalidFieldMappings = Object.values(
fieldCaps.fields[SERVICE_NAME] ?? {}
).filter(({ type }): boolean => type !== 'keyword');
@ -72,7 +76,7 @@ export function getIndicesStates({
const invalidIndices = items.filter((item) => !item.isValid);
const validIndices = items.filter((item) => item.isValid);
return { invalidIndices, validIndices };
return { invalidIndices, validIndices, indices, ingestPipelines, fieldCaps };
}
export function validateIngestPipelineName(

View file

@ -12,11 +12,11 @@ import { getNonDataStreamIndices } from './bundle/get_non_data_stream_indices';
import { getElasticsearchVersion } from './get_elasticsearch_version';
import { getIndexTemplatesByIndexPattern } from './bundle/get_index_templates_by_index_pattern';
import { getExistingApmIndexTemplates } from './bundle/get_existing_index_templates';
import { getFieldCaps } from './bundle/get_field_caps';
import { getIndicesAndIngestPipelines } from './bundle/get_indices';
import { getIndicesStates } from './bundle/get_indices_states';
import { getApmEvents } from './bundle/get_apm_events';
import { getApmIndexTemplates } from './helpers/get_apm_index_template_names';
import { handle403Exception } from './helpers/handle_403_exception';
import { getDiagnosticsPrivileges } from './helpers/get_diagnostic_privileges';
const DEFEAULT_START = Date.now() - 60 * 5 * 1000; // 5 minutes
const DEFAULT_END = Date.now();
@ -34,45 +34,73 @@ export async function getDiagnosticsBundle({
end: number | undefined;
kuery: string | undefined;
}) {
const { indices, ingestPipelines } = await getIndicesAndIngestPipelines({
const diagnosticsPrivileges = await getDiagnosticsPrivileges({
esClient,
apmIndices,
});
const indexTemplatesByIndexPattern = await getIndexTemplatesByIndexPattern({
esClient,
apmIndices,
});
const indexTemplatesByIndexPattern = await handle403Exception(
getIndexTemplatesByIndexPattern({
esClient,
apmIndices,
}),
[]
);
const existingIndexTemplates = await getExistingApmIndexTemplates({
esClient,
});
const existingIndexTemplates = await handle403Exception(
getExistingApmIndexTemplates({
esClient,
}),
[]
);
const fieldCaps = await getFieldCaps({ esClient, apmIndices });
const dataStreams = await getDataStreams({ esClient, apmIndices });
const nonDataStreamIndices = await getNonDataStreamIndices({
esClient,
apmIndices,
});
const dataStreams = await handle403Exception(
getDataStreams({ esClient, apmIndices }),
[]
);
const nonDataStreamIndices = await handle403Exception(
getNonDataStreamIndices({
esClient,
apmIndices,
}),
[]
);
const { invalidIndices, validIndices } = getIndicesStates({
fieldCaps,
indices,
ingestPipelines,
});
const { invalidIndices, validIndices, indices, ingestPipelines, fieldCaps } =
await handle403Exception(
getIndicesStates({
esClient,
apmIndices,
}),
{
invalidIndices: [],
validIndices: [],
indices: [],
ingestPipelines: [],
fieldCaps: {},
}
);
const apmEvents = await getApmEvents({
esClient,
apmIndices,
start,
end,
kuery,
});
const apmEvents = await handle403Exception(
getApmEvents({
esClient,
apmIndices,
start,
end,
kuery,
}),
[]
);
const elasticsearchVersion = await handle403Exception(
getElasticsearchVersion(esClient),
'N/A'
);
return {
created_at: new Date().toISOString(),
diagnosticsPrivileges,
apmIndices,
elasticsearchVersion: await getElasticsearchVersion(esClient),
elasticsearchVersion,
esResponses: {
fieldCaps,
indices,

View file

@ -5,16 +5,29 @@
* 2.0.
*/
import { FleetUnauthorizedError } from '@kbn/fleet-plugin/server/errors';
import { APMRouteHandlerResources } from '../typings';
export async function getFleetPackageInfo(resources: APMRouteHandlerResources) {
const fleetPluginStart = await resources.plugins.fleet?.start();
const packageInfo = await fleetPluginStart?.packageService
.asScoped(resources.request)
.getInstallation('apm');
return {
isInstalled: packageInfo?.install_status === 'installed',
version: packageInfo?.version,
};
try {
const packageInfo = await fleetPluginStart?.packageService
.asScoped(resources.request)
.getInstallation('apm');
return {
isInstalled: packageInfo?.install_status === 'installed',
version: packageInfo?.version,
};
} catch (e) {
if (e instanceof FleetUnauthorizedError) {
console.error('Insufficient permissions to access fleet package info');
return {
isInstalled: false,
version: 'N/A',
};
}
throw e;
}
}

View file

@ -0,0 +1,56 @@
/*
* 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 { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { ApmIndicesConfig } from '../../settings/apm_indices/get_apm_indices';
import { getApmIndexPatterns } from '../bundle/get_indices';
export async function getDiagnosticsPrivileges({
esClient,
apmIndices,
}: {
esClient: ElasticsearchClient;
apmIndices: ApmIndicesConfig;
}) {
const indexPatterns = getApmIndexPatterns([
apmIndices.error,
apmIndices.metric,
apmIndices.span,
apmIndices.transaction,
]);
const clusterPrivileges = [
'manage_index_templates',
'monitor',
'read_pipeline',
];
const { index, cluster } = await esClient.security.hasPrivileges({
body: {
index: [
{
names: indexPatterns,
privileges: ['read'],
},
],
cluster: clusterPrivileges,
},
});
const hasAllIndexPrivileges = Object.values(index).every((indexPrivs) =>
Object.values(indexPrivs).every((priv) => priv)
);
const hasAllClusterPrivileges = Object.values(cluster).every((priv) => priv);
return {
index,
cluster,
hasAllIndexPrivileges,
hasAllClusterPrivileges,
hasAllPrivileges: hasAllIndexPrivileges && hasAllClusterPrivileges,
};
}

View file

@ -0,0 +1,30 @@
/*
* 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 { errors } from '@elastic/elasticsearch';
export async function handle403Exception<T>(
promise: Promise<T>,
defaultValue: unknown
) {
try {
return await promise;
} catch (error) {
if (
error instanceof errors.ResponseError &&
error.meta.statusCode === 403
) {
console.error(`Suppressed insufficient access error: ${error.message}}`);
return defaultValue as T;
}
console.error(
`Unhandled error: ${error.message} ${JSON.stringify(error)}}`
);
throw error;
}
}

View file

@ -11,6 +11,7 @@ import {
IndicesGetIndexTemplateIndexTemplateItem,
IndicesGetResponse,
IngestGetPipelineResponse,
SecurityHasPrivilegesPrivileges,
} from '@elastic/elasticsearch/lib/api/types';
import * as t from 'io-ts';
import { isoToEpochRt } from '@kbn/io-ts-utils';
@ -56,6 +57,13 @@ const getDiagnosticsRoute = createApmServerRoute({
indices: IndicesGetResponse;
ingestPipelines: IngestGetPipelineResponse;
};
diagnosticsPrivileges: {
index: Record<string, SecurityHasPrivilegesPrivileges>;
cluster: Record<string, boolean>;
hasAllClusterPrivileges: boolean;
hasAllIndexPrivileges: boolean;
hasAllPrivileges: boolean;
};
apmIndices: ApmIndicesConfig;
apmIndexTemplates: Array<{
name: string;

View file

@ -39370,4 +39370,4 @@
"xpack.painlessLab.title": "Painless Lab",
"xpack.painlessLab.walkthroughButtonLabel": "Présentation"
}
}
}

View file

@ -39344,4 +39344,4 @@
"xpack.painlessLab.title": "Painless Lab",
"xpack.painlessLab.walkthroughButtonLabel": "実地検証"
}
}
}

View file

@ -39338,4 +39338,4 @@
"xpack.painlessLab.title": "Painless 实验室",
"xpack.painlessLab.walkthroughButtonLabel": "指导"
}
}
}

View file

@ -28,7 +28,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
});
it('returns zero data streams`', async () => {
const { status, body } = await apmApiClient.adminUser({
const { status, body } = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/diagnostics',
});
expect(status).to.be(200);
@ -59,7 +59,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
after(() => synthtraceEsClient.clean());
it('returns zero doc_counts when no time range is specified', async () => {
const { body } = await apmApiClient.adminUser({
const { body } = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/diagnostics',
});
@ -67,7 +67,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
});
it('returns non-zero doc_counts when time range is specified', async () => {
const { body } = await apmApiClient.adminUser({
const { body } = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/diagnostics',
params: {
query: { start: new Date(start).toISOString(), end: new Date(end).toISOString() },
@ -105,7 +105,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
const expectedDocCount = 450;
beforeEach(async () => {
const res = await apmApiClient.adminUser({
const res = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/diagnostics',
params: {
query: { start: new Date(start).toISOString(), end: new Date(end).toISOString() },
@ -168,7 +168,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
});
it('returns zero doc_counts when filtering by a non-existing service', async () => {
const { body } = await apmApiClient.adminUser({
const { body } = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/diagnostics',
params: {
query: {
@ -183,7 +183,7 @@ export default function ApiTest({ getService }: FtrProviderContext) {
});
it('returns non-zero doc_counts when filtering by an existing service', async () => {
const { body } = await apmApiClient.adminUser({
const { body } = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/diagnostics',
params: {
query: {

View file

@ -0,0 +1,87 @@
/*
* 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 { APIReturnType } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../common/ftr_provider_context';
export default function ApiTest({ getService }: FtrProviderContext) {
const registry = getService('registry');
const apmApiClient = getService('apmApiClient');
registry.when('Diagnostics: Privileges', { config: 'basic', archives: [] }, () => {
describe('superuser', () => {
let body: APIReturnType<'GET /internal/apm/diagnostics'>;
beforeEach(async () => {
const res = await apmApiClient.adminUser({
endpoint: 'GET /internal/apm/diagnostics',
});
body = res.body;
expect(res.status).to.be(200);
});
it('has all privileges', async () => {
expect(body.diagnosticsPrivileges.hasAllPrivileges).to.be(true);
});
it('has all index privileges', async () => {
expect(body.diagnosticsPrivileges.hasAllIndexPrivileges).to.be(true);
expect(body.diagnosticsPrivileges.index).to.eql({
'apm-*': { read: true },
'logs-apm*': { read: true },
'metrics-apm*': { read: true },
'traces-apm*': { read: true },
});
});
it('has all cluster privileges', async () => {
expect(body.diagnosticsPrivileges.hasAllClusterPrivileges).to.be(true);
expect(body.diagnosticsPrivileges.cluster).to.eql({
read_pipeline: true,
manage_index_templates: true,
monitor: true,
});
});
});
describe('viewer', () => {
let body: APIReturnType<'GET /internal/apm/diagnostics'>;
beforeEach(async () => {
const res = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/diagnostics',
});
body = res.body;
expect(res.status).to.be(200);
});
it('does not have all privileges', async () => {
expect(body.diagnosticsPrivileges.hasAllPrivileges).to.be(false);
});
it('has all index privileges', async () => {
expect(body.diagnosticsPrivileges.hasAllIndexPrivileges).to.be(true);
expect(body.diagnosticsPrivileges.index).to.eql({
'apm-*': { read: true },
'logs-apm*': { read: true },
'metrics-apm*': { read: true },
'traces-apm*': { read: true },
});
});
it('does not have any cluster privileges', async () => {
expect(body.diagnosticsPrivileges.hasAllClusterPrivileges).to.be(false);
expect(body.diagnosticsPrivileges.cluster).to.eql({
read_pipeline: false,
manage_index_templates: false,
monitor: false,
});
});
});
});
}