From afbd4dec5206ff57eeaf59b78ee18228b72d170e Mon Sep 17 00:00:00 2001 From: Sergey Kostrukov Date: Wed, 19 May 2021 12:48:53 -0700 Subject: [PATCH] AzureMonitor: Managed Identity configuration UI (#34170) * Basic UI for Managed Identity * Credentials logic * Fix datasource validation * Do not offer Managed Identity for Log Analytics * Logic fixes * Show Log Analytics credentials only for App Registration * Fix tests * Datasource validation refactoring --- .../app_insights/app_insights_datasource.ts | 6 +- .../azure_log_analytics_datasource.ts | 56 +- .../azure_monitor/azure_monitor_datasource.ts | 55 +- .../components/AnalyticsConfig.tsx | 57 +- .../components/AzureCredentialsForm.test.tsx | 4 + .../components/AzureCredentialsForm.tsx | 211 ++-- .../components/InsightsConfig.tsx | 2 +- .../components/MonitorConfig.tsx | 4 +- .../AnalyticsConfig.test.tsx.snap | 10 +- .../AzureCredentialsForm.test.tsx.snap | 1004 ++++++++--------- .../InsightsConfig.test.tsx.snap | 6 +- .../credentials.ts | 237 ++-- .../datasource.ts | 16 +- .../types/index.ts | 29 +- 14 files changed, 960 insertions(+), 737 deletions(-) diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.ts index 51ecdcf136e..07c93a56da4 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/app_insights/app_insights_datasource.ts @@ -3,7 +3,7 @@ import { getBackendSrv, getTemplateSrv, DataSourceWithBackend } from '@grafana/r import { isString } from 'lodash'; import TimegrainConverter from '../time_grain_converter'; -import { AzureDataSourceJsonData, AzureMonitorQuery, AzureQueryType } from '../types'; +import { AzureDataSourceJsonData, AzureMonitorQuery, AzureQueryType, DatasourceValidationResult } from '../types'; import ResponseParser from './response_parser'; import { getAzureCloud } from '../credentials'; import { getAppInsightsApiRoute } from '../api/routes'; @@ -132,10 +132,10 @@ export default class AppInsightsDatasource extends DataSourceWithBackend { const url = `${this.baseUrl}/metrics/metadata`; return this.doRequest(url) - .then((response: any) => { + .then((response: any) => { if (response.status === 200) { return { status: 'success', diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts index d7664c8798d..48aa8510416 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_log_analytics/azure_log_analytics_datasource.ts @@ -1,7 +1,13 @@ import { map } from 'lodash'; import LogAnalyticsQuerystringBuilder from '../log_analytics/querystring_builder'; import ResponseParser, { transformMetadataToKustoSchema } from './response_parser'; -import { AzureMonitorQuery, AzureDataSourceJsonData, AzureLogsVariable, AzureQueryType } from '../types'; +import { + AzureMonitorQuery, + AzureDataSourceJsonData, + AzureLogsVariable, + AzureQueryType, + DatasourceValidationResult, +} from '../types'; import { DataQueryRequest, DataQueryResponse, @@ -12,7 +18,7 @@ import { import { getBackendSrv, getTemplateSrv, DataSourceWithBackend, FetchResponse } from '@grafana/runtime'; import { Observable, from } from 'rxjs'; import { mergeMap } from 'rxjs/operators'; -import { getAzureCloud } from '../credentials'; +import { getAuthType, getAzureCloud } from '../credentials'; import { getLogAnalyticsApiRoute, getLogAnalyticsManagementApiRoute } from '../api/routes'; import { AzureLogAnalyticsMetadata } from '../types/logAnalyticsMetadata'; @@ -349,8 +355,8 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend< } // TODO: update to be resource-centric - testDatasource(): Promise { - const validationError = this.isValidConfig(); + testDatasource(): Promise { + const validationError = this.validateDatasource(); if (validationError) { return Promise.resolve(validationError); } @@ -361,7 +367,7 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend< return this.doRequest(url); }) - .then((response: any) => { + .then((response: any) => { if (response.status === 200) { return { status: 'success', @@ -404,36 +410,36 @@ export default class AzureLogAnalyticsDatasource extends DataSourceWithBackend< return message; } - isValidConfig() { - if (this.instanceSettings.jsonData.azureLogAnalyticsSameAs) { - return undefined; + private validateDatasource(): DatasourceValidationResult | undefined { + const authType = getAuthType(this.instanceSettings); + + if (authType === 'clientsecret') { + if (!this.isValidConfigField(this.instanceSettings.jsonData.logAnalyticsTenantId)) { + return { + status: 'error', + message: 'The Tenant Id field is required.', + }; + } + + if (!this.isValidConfigField(this.instanceSettings.jsonData.logAnalyticsClientId)) { + return { + status: 'error', + message: 'The Client Id field is required.', + }; + } } - if (!this.isValidConfigField(this.instanceSettings.jsonData.logAnalyticsSubscriptionId)) { + if (!this.isValidConfigField(this.subscriptionId)) { return { status: 'error', message: 'The Subscription Id field is required.', }; } - if (!this.isValidConfigField(this.instanceSettings.jsonData.logAnalyticsTenantId)) { - return { - status: 'error', - message: 'The Tenant Id field is required.', - }; - } - - if (!this.isValidConfigField(this.instanceSettings.jsonData.logAnalyticsClientId)) { - return { - status: 'error', - message: 'The Client Id field is required.', - }; - } - return undefined; } - isValidConfigField(field: string | undefined) { - return field && field.length > 0; + private isValidConfigField(field: string | undefined): boolean { + return typeof field === 'string' && field.length > 0; } } diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts index 5192821abca..ae37a4a6c3a 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/azure_monitor_datasource.ts @@ -11,6 +11,7 @@ import { AzureQueryType, AzureMonitorMetricsMetadataResponse, AzureMetricQuery, + DatasourceValidationResult, } from '../types'; import { DataSourceInstanceSettings, @@ -25,7 +26,7 @@ import { from, Observable } from 'rxjs'; import { mergeMap } from 'rxjs/operators'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; -import { getAzureCloud } from '../credentials'; +import { getAuthType, getAzureCloud } from '../credentials'; import { getManagementApiRoute } from '../api/routes'; const defaultDropdownValue = 'select'; @@ -458,24 +459,15 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend { - if (!this.isValidConfigField(this.instanceSettings.jsonData.tenantId)) { - return Promise.resolve({ - status: 'error', - message: 'The Tenant Id field is required.', - }); - } - - if (!this.isValidConfigField(this.instanceSettings.jsonData.clientId)) { - return Promise.resolve({ - status: 'error', - message: 'The Client Id field is required.', - }); + testDatasource(): Promise { + const validationError = this.validateDatasource(); + if (validationError) { + return Promise.resolve(validationError); } const url = `${this.baseUrl}?api-version=2019-03-01`; return this.doRequest(url) - .then((response: any) => { + .then((response: any) => { if (response.status === 200) { return { status: 'success', @@ -509,8 +501,37 @@ export default class AzureMonitorDatasource extends DataSourceWithBackend 0; + private validateDatasource(): DatasourceValidationResult | undefined { + const authType = getAuthType(this.instanceSettings); + + if (authType === 'clientsecret') { + if (!this.isValidConfigField(this.instanceSettings.jsonData.tenantId)) { + return { + status: 'error', + message: 'The Tenant Id field is required.', + }; + } + + if (!this.isValidConfigField(this.instanceSettings.jsonData.clientId)) { + return { + status: 'error', + message: 'The Client Id field is required.', + }; + } + } + + if (!this.isValidConfigField(this.subscriptionId)) { + return { + status: 'error', + message: 'The Subscription Id field is required.', + }; + } + + return undefined; + } + + private isValidConfigField(field?: string): boolean { + return typeof field === 'string' && field.length > 0; } doRequest(url: string, maxRetries = 1): Promise> { diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.tsx index 4a8867ec612..f24736a6d04 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AnalyticsConfig.tsx @@ -27,6 +27,8 @@ export const AnalyticsConfig: FunctionComponent = (props: Props) => { ? props.options.jsonData.logAnalyticsSubscriptionId : props.options.jsonData.subscriptionId; + const credentialsEnabled = primaryCredentials.authType === 'clientsecret'; + const hasRequiredFields = subscriptionId && (logAnalyticsCredentials @@ -127,32 +129,41 @@ export const AnalyticsConfig: FunctionComponent = (props: Props) => { }), }; - const showSameAsHelpMsg = sameAsSwitched && !primaryCredentials.clientSecret; + const showSameAsHelpMsg = + credentialsEnabled && + sameAsSwitched && + primaryCredentials.authType === 'clientsecret' && + !primaryCredentials.clientSecret; return ( <> -

Azure Monitor Logs Details

- - {showSameAsHelpMsg && ( -
-
-

Re-enter your Azure Monitor Client Secret to use this setting.

-
-
- )} - {logAnalyticsCredentials && ( - +

Azure Monitor Logs

+ {credentialsEnabled && ( + <> + + {showSameAsHelpMsg && ( +
+
+

Re-enter your Azure Monitor Client Secret to use this setting.

+
+
+ )} + {logAnalyticsCredentials && ( + + )} + )}
diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.test.tsx index 507e9ccdeea..7dc0dfa5975 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.test.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.test.tsx @@ -4,7 +4,9 @@ import AzureCredentialsForm, { Props } from './AzureCredentialsForm'; const setup = (propsFunc?: (props: Props) => Props) => { let props: Props = { + managedIdentityEnabled: false, credentials: { + authType: 'clientsecret', azureCloud: 'azuremonitor', tenantId: 'e7f3f661-a933-3h3f-0294-31c4f962ec48', clientId: '34509fad-c0r9-45df-9e25-f1ee34af6900', @@ -39,6 +41,7 @@ describe('Render', () => { const wrapper = setup((props) => ({ ...props, credentials: { + authType: 'clientsecret', azureCloud: 'azuremonitor', tenantId: 'e7f3f661-a933-3h3f-0294-31c4f962ec48', clientId: '34509fad-c0r9-45df-9e25-f1ee34af6900', @@ -52,6 +55,7 @@ describe('Render', () => { const wrapper = setup((props) => ({ ...props, credentials: { + authType: 'clientsecret', azureCloud: 'azuremonitor', tenantId: 'e7f3f661-a933-3h3f-0294-31c4f962ec48', clientId: '34509fad-c0r9-45df-9e25-f1ee34af6900', diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx index 5ce8389a109..6f07dcb3824 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/AzureCredentialsForm.tsx @@ -1,11 +1,12 @@ import React, { ChangeEvent, FunctionComponent, useEffect, useReducer, useState } from 'react'; import { SelectableValue } from '@grafana/data'; import { InlineFormLabel, LegacyForms, Button } from '@grafana/ui'; -import { AzureCredentials } from '../types'; +import { AzureAuthType, AzureCredentials } from '../types'; import { isCredentialsComplete } from '../credentials'; const { Select, Input } = LegacyForms; export interface Props { + managedIdentityEnabled: boolean; credentials: AzureCredentials; defaultSubscription?: string; azureCloudOptions?: SelectableValue[]; @@ -14,6 +15,17 @@ export interface Props { getSubscriptions?: () => Promise; } +const authTypeOptions: Array> = [ + { + value: 'msi', + label: 'Managed Identity', + }, + { + value: 'clientsecret', + label: 'App Registration', + }, +]; + export const AzureCredentialsForm: FunctionComponent = (props: Props) => { const { credentials, @@ -61,8 +73,18 @@ export const AzureCredentialsForm: FunctionComponent = (props: Props) => } }; - const onAzureCloudChange = (selected: SelectableValue) => { + const onAuthTypeChange = (selected: SelectableValue) => { if (onCredentialsChange) { + const updated: AzureCredentials = { + ...credentials, + authType: selected.value || 'msi', + }; + onCredentialsChange(updated); + } + }; + + const onAzureCloudChange = (selected: SelectableValue) => { + if (onCredentialsChange && credentials.authType === 'clientsecret') { const updated: AzureCredentials = { ...credentials, azureCloud: selected.value, @@ -72,7 +94,7 @@ export const AzureCredentialsForm: FunctionComponent = (props: Props) => }; const onTenantIdChange = (event: ChangeEvent) => { - if (onCredentialsChange) { + if (onCredentialsChange && credentials.authType === 'clientsecret') { const updated: AzureCredentials = { ...credentials, tenantId: event.target.value, @@ -82,7 +104,7 @@ export const AzureCredentialsForm: FunctionComponent = (props: Props) => }; const onClientIdChange = (event: ChangeEvent) => { - if (onCredentialsChange) { + if (onCredentialsChange && credentials.authType === 'clientsecret') { const updated: AzureCredentials = { ...credentials, clientId: event.target.value, @@ -92,7 +114,7 @@ export const AzureCredentialsForm: FunctionComponent = (props: Props) => }; const onClientSecretChange = (event: ChangeEvent) => { - if (onCredentialsChange) { + if (onCredentialsChange && credentials.authType === 'clientsecret') { const updated: AzureCredentials = { ...credentials, clientSecret: event.target.value, @@ -102,7 +124,7 @@ export const AzureCredentialsForm: FunctionComponent = (props: Props) => }; const onClientSecretReset = () => { - if (onCredentialsChange) { + if (onCredentialsChange && credentials.authType === 'clientsecret') { const updated: AzureCredentials = { ...credentials, clientSecret: '', @@ -118,111 +140,128 @@ export const AzureCredentialsForm: FunctionComponent = (props: Props) => }; return ( - <> -
- {azureCloudOptions && ( -
-
- - Azure Cloud - - -
+ + Authentication + + -
-
-
- {typeof credentials.clientSecret === 'symbol' ? ( -
-
- Client Secret - -
-
-
- + )} + {credentials.authType === 'clientsecret' && ( + <> + {azureCloudOptions && ( +
+
+ + Azure Cloud + +
- )} - {getSubscriptions && onDefaultSubscriptionChange && ( - <> -
-
- Default Subscription -
-
+
+ {typeof credentials.clientSecret === 'symbol' ? (
+
+ Client Secret + +
-
- - )} -
- + ) : ( +
+
+ Client Secret +
+ +
+
+
+ )} + + )} + {getSubscriptions && onDefaultSubscriptionChange && ( + <> +
+
+ Default Subscription +
+ +
+
+
+
+ + Directory (tenant) ID + +
+ +
+
+
+
+
+ + Application (client) ID + +
+ +
+
+
+
+
+ + Client Secret + + +
+
- - Azure Cloud - + reset + +
+
+
+
+
+ + Default Subscription + +
-
-
-
-
-
- - Application (client) ID - -
- -
-
-
-
-
- - Client Secret - - -
-
-
- -
-
-
-
-
- - Default Subscription - -
- +
+
+
+
+ + Directory (tenant) ID +
+ +
+
+
+
+
+ + Application (client) ID + +
+ +
+
+
+
+
+ + Client Secret + +
+ +
+
+
+
+
+ + Default Subscription + +
- - Azure Cloud - -
-
-
-
-
- - Application (client) ID - -
- -
-
-
-
-
- - Client Secret - -
- -
-
-
-
-
- - Default Subscription - -
- +
+
+
+
+ + Directory (tenant) ID +
+ +
+
+
+
+
+ + Application (client) ID + +
+ +
+
+
+
+
+ + Client Secret + +
+ +
+
+
+
+
+ + Default Subscription + +
- - Azure Cloud - -
-
-
-
-
- - Application (client) ID - -
- -
-
-
-
-
- - Client Secret - -
- -
-
-
-
-
- - Default Subscription - -
-