diff --git a/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx b/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx index 9b2721bd897..9efd5548e8b 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx +++ b/packages/grafana-ui/src/components/DataSourceSettings/DataSourceHttpSettings.tsx @@ -56,7 +56,14 @@ const HttpAccessHelp = () => ( ); export const DataSourceHttpSettings: React.FC = (props) => { - const { defaultUrl, dataSourceConfig, onChange, showAccessOptions, sigV4AuthToggleEnabled } = props; + const { + defaultUrl, + dataSourceConfig, + onChange, + showAccessOptions, + sigV4AuthToggleEnabled, + azureAuthSettings, + } = props; let urlTooltip; const [isAccessHelpVisible, setIsAccessHelpVisible] = useState(false); const theme = useTheme(); @@ -207,6 +214,22 @@ export const DataSourceHttpSettings: React.FC = (props) => { /> + {azureAuthSettings?.azureAuthEnabled && ( +
+ { + onSettingsChange({ + jsonData: { ...dataSourceConfig.jsonData, azureAuth: event!.currentTarget.checked }, + }); + }} + tooltip="Use Azure authentication for Azure endpoint." + /> +
+ )} + {sigV4AuthToggleEnabled && (
= (props) => { )} + {azureAuthSettings?.azureAuthEnabled && + azureAuthSettings?.azureSettingsUI && + dataSourceConfig.jsonData.azureAuth && ( + + )} + {dataSourceConfig.jsonData.sigV4Auth && } {(dataSourceConfig.jsonData.tlsAuth || dataSourceConfig.jsonData.tlsAuthWithCACert) && ( diff --git a/packages/grafana-ui/src/components/DataSourceSettings/types.ts b/packages/grafana-ui/src/components/DataSourceSettings/types.ts index 95792ca9ceb..ddb803fdf86 100644 --- a/packages/grafana-ui/src/components/DataSourceSettings/types.ts +++ b/packages/grafana-ui/src/components/DataSourceSettings/types.ts @@ -1,5 +1,11 @@ +import React from 'react'; import { DataSourceSettings } from '@grafana/data'; +export interface AzureAuthSettings { + azureAuthEnabled: boolean; + azureSettingsUI?: React.ComponentType; +} + export interface HttpSettingsBaseProps { /** The configuration object of the data source */ dataSourceConfig: DataSourceSettings; @@ -14,4 +20,6 @@ export interface HttpSettingsProps extends HttpSettingsBaseProps { showAccessOptions?: boolean; /** Show the SigV4 auth toggle option */ sigV4AuthToggleEnabled?: boolean; + /** Azure authentication settings **/ + azureAuthSettings?: AzureAuthSettings; } diff --git a/public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx b/public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx new file mode 100644 index 00000000000..77246455cd0 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/AzureAuthSettings.tsx @@ -0,0 +1,51 @@ +import React, { FunctionComponent, useMemo } from 'react'; +import { InlineFormLabel, Input } from '@grafana/ui'; +import { config } from '@grafana/runtime'; +import { KnownAzureClouds, AzureCredentials } from './AzureCredentials'; +import { getCredentials, updateCredentials } from './AzureCredentialsConfig'; +import { AzureCredentialsForm } from './AzureCredentialsForm'; +import { HttpSettingsBaseProps } from '@grafana/ui/src/components/DataSourceSettings/types'; + +export const AzureAuthSettings: FunctionComponent = (props: HttpSettingsBaseProps) => { + const { dataSourceConfig, onChange } = props; + + const credentials = useMemo(() => getCredentials(dataSourceConfig), [dataSourceConfig]); + + const onCredentialsChange = (credentials: AzureCredentials): void => { + onChange(updateCredentials(dataSourceConfig, credentials)); + }; + + return ( + <> +
Azure Authentication
+ +
Azure Configuration
+
+
+
+ AAD resource ID +
+ + onChange({ + ...dataSourceConfig, + jsonData: { ...dataSourceConfig.jsonData, azureEndpointResourceId: event.currentTarget.value }, + }) + } + /> +
+
+
+
+ + ); +}; + +export default AzureAuthSettings; diff --git a/public/app/plugins/datasource/prometheus/configuration/AzureCredentials.ts b/public/app/plugins/datasource/prometheus/configuration/AzureCredentials.ts new file mode 100644 index 00000000000..268e18f1e73 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/AzureCredentials.ts @@ -0,0 +1,48 @@ +import { SelectableValue } from '@grafana/data'; + +export enum AzureCloud { + Public = 'AzureCloud', + China = 'AzureChinaCloud', + USGovernment = 'AzureUSGovernment', + Germany = 'AzureGermanCloud', + None = '', +} + +export const KnownAzureClouds = [ + { value: AzureCloud.Public, label: 'Azure' }, + { value: AzureCloud.China, label: 'Azure China' }, + { value: AzureCloud.USGovernment, label: 'Azure US Government' }, + { value: AzureCloud.Germany, label: 'Azure Germany' }, +] as SelectableValue[]; + +export type AzureAuthType = 'msi' | 'clientsecret'; + +export type ConcealedSecret = symbol; + +interface AzureCredentialsBase { + authType: AzureAuthType; + defaultSubscriptionId?: string; +} + +export interface AzureManagedIdentityCredentials extends AzureCredentialsBase { + authType: 'msi'; +} + +export interface AzureClientSecretCredentials extends AzureCredentialsBase { + authType: 'clientsecret'; + azureCloud?: string; + tenantId?: string; + clientId?: string; + clientSecret?: string | ConcealedSecret; +} + +export type AzureCredentials = AzureManagedIdentityCredentials | AzureClientSecretCredentials; + +export function isCredentialsComplete(credentials: AzureCredentials): boolean { + switch (credentials.authType) { + case 'msi': + return true; + case 'clientsecret': + return !!(credentials.azureCloud && credentials.tenantId && credentials.clientId && credentials.clientSecret); + } +} diff --git a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsConfig.ts b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsConfig.ts new file mode 100644 index 00000000000..c109e09a6d5 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsConfig.ts @@ -0,0 +1,107 @@ +import { DataSourceSettings } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { AzureCloud, AzureCredentials, ConcealedSecret } from './AzureCredentials'; + +const concealed: ConcealedSecret = Symbol('Concealed client secret'); + +function getDefaultAzureCloud(): string { + return config.azure.cloud || AzureCloud.Public; +} + +function getSecret(options: DataSourceSettings): undefined | string | ConcealedSecret { + if (options.secureJsonFields.azureClientSecret) { + // The secret is concealed on server + return concealed; + } else { + const secret = options.secureJsonData?.azureClientSecret; + return typeof secret === 'string' && secret.length > 0 ? secret : undefined; + } +} + +export function getCredentials(options: DataSourceSettings): AzureCredentials { + const credentials = options.jsonData.azureCredentials as AzureCredentials | undefined; + + // If no credentials saved, then return empty credentials + // of type based on whether the managed identity enabled + if (!credentials) { + return { + authType: config.azure.managedIdentityEnabled ? 'msi' : 'clientsecret', + azureCloud: getDefaultAzureCloud(), + }; + } + + switch (credentials.authType) { + case 'msi': + if (config.azure.managedIdentityEnabled) { + return { + authType: 'msi', + }; + } else { + // If authentication type is managed identity but managed identities were disabled in Grafana config, + // then we should fallback to an empty app registration (client secret) configuration + return { + authType: 'clientsecret', + azureCloud: getDefaultAzureCloud(), + }; + } + case 'clientsecret': + return { + authType: 'clientsecret', + azureCloud: credentials.azureCloud || getDefaultAzureCloud(), + tenantId: credentials.tenantId, + clientId: credentials.clientId, + clientSecret: getSecret(options), + }; + } +} + +export function updateCredentials( + options: DataSourceSettings, + credentials: AzureCredentials +): DataSourceSettings { + switch (credentials.authType) { + case 'msi': + if (!config.azure.managedIdentityEnabled) { + throw new Error('Managed Identity authentication is not enabled in Grafana config.'); + } + + options = { + ...options, + jsonData: { + ...options.jsonData, + azureCredentials: { + authType: 'msi', + }, + }, + }; + + return options; + + case 'clientsecret': + options = { + ...options, + jsonData: { + ...options.jsonData, + azureCredentials: { + authType: 'clientsecret', + azureCloud: credentials.azureCloud || getDefaultAzureCloud(), + tenantId: credentials.tenantId, + clientId: credentials.clientId, + }, + }, + secureJsonData: { + ...options.secureJsonData, + azureClientSecret: + typeof credentials.clientSecret === 'string' && credentials.clientSecret.length > 0 + ? credentials.clientSecret + : undefined, + }, + secureJsonFields: { + ...options.secureJsonFields, + azureClientSecret: typeof credentials.clientSecret === 'symbol', + }, + }; + + return options; + } +} diff --git a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.test.tsx b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.test.tsx new file mode 100644 index 00000000000..52a403a246b --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.test.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +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', + clientSecret: undefined, + defaultSubscriptionId: '44987801-6nn6-49he-9b2d-9106972f9789', + }, + azureCloudOptions: [ + { value: 'azuremonitor', label: 'Azure' }, + { value: 'govazuremonitor', label: 'Azure US Government' }, + { value: 'germanyazuremonitor', label: 'Azure Germany' }, + { value: 'chinaazuremonitor', label: 'Azure China' }, + ], + onCredentialsChange: jest.fn(), + getSubscriptions: jest.fn(), + }; + + if (propsFunc) { + props = propsFunc(props); + } + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + expect(wrapper).toMatchSnapshot(); + }); + + it('should disable azure monitor secret input', () => { + const wrapper = setup((props) => ({ + ...props, + credentials: { + authType: 'clientsecret', + azureCloud: 'azuremonitor', + tenantId: 'e7f3f661-a933-3h3f-0294-31c4f962ec48', + clientId: '34509fad-c0r9-45df-9e25-f1ee34af6900', + clientSecret: Symbol(), + }, + })); + expect(wrapper).toMatchSnapshot(); + }); + + it('should enable azure monitor load subscriptions button', () => { + const wrapper = setup((props) => ({ + ...props, + credentials: { + authType: 'clientsecret', + azureCloud: 'azuremonitor', + tenantId: 'e7f3f661-a933-3h3f-0294-31c4f962ec48', + clientId: '34509fad-c0r9-45df-9e25-f1ee34af6900', + clientSecret: 'e7f3f661-a933-4b3f-8176-51c4f982ec48', + }, + })); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx new file mode 100644 index 00000000000..f13afc23c0a --- /dev/null +++ b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx @@ -0,0 +1,279 @@ +import React, { ChangeEvent, FunctionComponent, useEffect, useReducer, useState } from 'react'; +import { SelectableValue } from '@grafana/data'; +import { InlineFormLabel, Button } from '@grafana/ui/src/components'; +import { Select } from '@grafana/ui/src/components/Forms/Legacy/Select/Select'; +import { Input } from '@grafana/ui/src/components/Forms/Legacy/Input/Input'; +import { AzureAuthType, AzureCredentials, isCredentialsComplete } from './AzureCredentials'; + +export interface Props { + managedIdentityEnabled: boolean; + credentials: AzureCredentials; + azureCloudOptions?: SelectableValue[]; + onCredentialsChange: (updatedCredentials: AzureCredentials) => void; + getSubscriptions?: () => Promise; +} + +const authTypeOptions: Array> = [ + { + value: 'msi', + label: 'Managed Identity', + }, + { + value: 'clientsecret', + label: 'App Registration', + }, +]; + +export const AzureCredentialsForm: FunctionComponent = (props: Props) => { + const { credentials, azureCloudOptions, onCredentialsChange, getSubscriptions } = props; + const hasRequiredFields = isCredentialsComplete(credentials); + + const [subscriptions, setSubscriptions] = useState>>([]); + const [loadSubscriptionsClicked, onLoadSubscriptions] = useReducer((val) => val + 1, 0); + useEffect(() => { + if (!getSubscriptions || !hasRequiredFields) { + updateSubscriptions([]); + return; + } + let canceled = false; + getSubscriptions().then((result) => { + if (!canceled) { + updateSubscriptions(result, loadSubscriptionsClicked); + } + }); + return () => { + canceled = true; + }; + // This effect is intended to be called only once initially and on Load Subscriptions click + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadSubscriptionsClicked]); + + const updateSubscriptions = (received: Array>, autoSelect = false) => { + setSubscriptions(received); + if (getSubscriptions) { + if (autoSelect && !credentials.defaultSubscriptionId && received.length > 0) { + // Selecting the default subscription if subscriptions received but no default subscription selected + onSubscriptionChange(received[0]); + } else if (credentials.defaultSubscriptionId) { + const found = received.find((opt) => opt.value === credentials.defaultSubscriptionId); + if (!found) { + // Unselecting the default subscription if it isn't found among the received subscriptions + onSubscriptionChange(undefined); + } + } + } + }; + + const onAuthTypeChange = (selected: SelectableValue) => { + if (onCredentialsChange) { + setSubscriptions([]); + const updated: AzureCredentials = { + ...credentials, + authType: selected.value || 'msi', + defaultSubscriptionId: undefined, + }; + onCredentialsChange(updated); + } + }; + + const onAzureCloudChange = (selected: SelectableValue) => { + if (onCredentialsChange && credentials.authType === 'clientsecret') { + setSubscriptions([]); + const updated: AzureCredentials = { + ...credentials, + azureCloud: selected.value, + defaultSubscriptionId: undefined, + }; + onCredentialsChange(updated); + } + }; + + const onTenantIdChange = (event: ChangeEvent) => { + if (onCredentialsChange && credentials.authType === 'clientsecret') { + setSubscriptions([]); + const updated: AzureCredentials = { + ...credentials, + tenantId: event.target.value, + defaultSubscriptionId: undefined, + }; + onCredentialsChange(updated); + } + }; + + const onClientIdChange = (event: ChangeEvent) => { + if (onCredentialsChange && credentials.authType === 'clientsecret') { + setSubscriptions([]); + const updated: AzureCredentials = { + ...credentials, + clientId: event.target.value, + defaultSubscriptionId: undefined, + }; + onCredentialsChange(updated); + } + }; + + const onClientSecretChange = (event: ChangeEvent) => { + if (onCredentialsChange && credentials.authType === 'clientsecret') { + setSubscriptions([]); + const updated: AzureCredentials = { + ...credentials, + clientSecret: event.target.value, + defaultSubscriptionId: undefined, + }; + onCredentialsChange(updated); + } + }; + + const onClientSecretReset = () => { + if (onCredentialsChange && credentials.authType === 'clientsecret') { + setSubscriptions([]); + const updated: AzureCredentials = { + ...credentials, + clientSecret: '', + defaultSubscriptionId: undefined, + }; + onCredentialsChange(updated); + } + }; + + const onSubscriptionChange = (selected: SelectableValue | undefined) => { + if (onCredentialsChange) { + const updated: AzureCredentials = { + ...credentials, + defaultSubscriptionId: selected?.value, + }; + onCredentialsChange(updated); + } + }; + + return ( +
+ {props.managedIdentityEnabled && ( +
+
+ + Authentication + + opt.value === credentials.azureCloud)} + options={azureCloudOptions} + onChange={onAzureCloudChange} + /> +
+
+ )} +
+
+ Directory (tenant) ID +
+ +
+
+
+
+
+ Application (client) ID +
+ +
+
+
+ {typeof credentials.clientSecret === 'symbol' ? ( +
+
+ Client Secret + +
+
+
+ +
+
+
+ ) : ( +
+
+ Client Secret +
+ +
+
+
+ )} + + )} + {getSubscriptions && ( + <> +
+
+ Default Subscription +
+ +
+
+
+
+ + Directory (tenant) ID + +
+ +
+
+
+
+
+ + Application (client) ID + +
+ +
+
+
+
+
+ + Client Secret + + +
+
+
+ +
+
+
+
+
+ + Default Subscription + +
+ +
+
+
+
+ + Directory (tenant) ID + +
+ +
+
+
+
+
+ + Application (client) ID + +
+ +
+
+
+
+
+ + Client Secret + +
+ +
+
+
+
+
+ + Default Subscription + +
+ +
+
+
+
+ + Directory (tenant) ID + +
+ +
+
+
+
+
+ + Application (client) ID + +
+ +
+
+
+
+
+ + Client Secret + +
+ +
+
+
+
+
+ + Default Subscription + +
+