diff --git a/docs/sources/datasources/azure-monitor/_index.md b/docs/sources/datasources/azure-monitor/_index.md index f52fd56d967..130ffdd79ba 100644 --- a/docs/sources/datasources/azure-monitor/_index.md +++ b/docs/sources/datasources/azure-monitor/_index.md @@ -63,6 +63,9 @@ For more information, refer to [Azure documentation for role assignments](https: If you host Grafana in Azure, such as in App Service or Azure Virtual Machines, you can configure the Azure Monitor data source to use Managed Identity for secure authentication without entering credentials into Grafana. For details, refer to [Configuring using Managed Identity](#configuring-using-managed-identity). +You can configure the Azure Monitor data source to use Workload Identity for secure authentication without entering credentials into Grafana if you host Grafana in a Kubernetes environment, such as AKS, and require access to Azure resources. +For details, refer to [Configuring using Workload Identity](#configuring-using-workload-identity). + | Name | Description | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Authentication** | Enables Managed Identity. Selecting Managed Identity hides many of the other fields. For details, see [Configuring using Managed Identity](#configuring-using-managed-identity). | @@ -114,6 +117,21 @@ datasources: version: 1 ``` +**Workload Identity:** + +```yaml +apiVersion: 1 # config file version + +datasources: + - name: Azure Monitor + type: grafana-azure-monitor-datasource + access: proxy + jsonData: + azureAuthType: workloadidentity + subscriptionId: # Optional, default subscription + version: 1 +``` + #### Supported cloud names | Azure Cloud | `cloudName` Value | @@ -124,8 +142,8 @@ datasources: ### Configure Managed Identity -If you host Grafana in Azure, such as an App Service or with Azure Virtual Machines, and have managed identity enabled on your VM, you can use managed identity to configure Azure Monitor in Grafana. -This lets you securely authenticate data sources without manually configuring credentials via Azure AD App Registrations for each. +You can use managed identity to configure Azure Monitor in Grafana if you host Grafana in Azure (such as an App Service or with Azure Virtual Machines) and have managed identity enabled on your VM. +This lets you securely authenticate data sources without manually configuring credentials via Azure AD App Registrations. For details on Azure managed identities, refer to the [Azure documentation](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview). **To enable managed identity for Grafana:** @@ -141,7 +159,46 @@ For details on Azure managed identities, refer to the [Azure documentation](http This hides the directory ID, application ID, and client secret fields, and the data source uses managed identity to authenticate to Azure Monitor Metrics and Logs, and Azure Resource Graph. - {{< figure src="/media/docs/grafana/data-sources/screenshot-managed-identity.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Monitor Metrics screenshot showing Dimensions" >}} + {{< figure src="/media/docs/grafana/data-sources/screenshot-managed-identity-2.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Monitor screenshot showing Managed Identity authentication" >}} + +3. You can set the `managed_identity_client_id` field in the `[azure]` section of the [Grafana server configuration][configure-grafana-azure] to allow a user-assigned managed identity to be used instead of the default system-assigned identity. + +```ini +[azure] +managed_identity_enabled = true +managed_identity_client_id = USER_ASSIGNED_IDENTITY_CLIENT_ID +``` + +### Configure Workload Identity + +You can use workload identity to configure Azure Monitor in Grafana if you host Grafana in a Kubernetes environment, such as AKS, in conjunction with managed identities. +This lets you securely authenticate data sources without manually configuring credentials via Azure AD App Registrations. +For details on workload identity, refer to the [Azure workload identity documentation](https://azure.github.io/azure-workload-identity/docs/). + +**To enable workload identity for Grafana:** + +1. Set the `workload_identity_enabled` flag in the `[azure]` section of the [Grafana server configuration][configure-grafana-azure]. + + ```ini + [azure] + workload_identity_enabled = true + ``` + +2. In the Azure Monitor data source configuration, set **Authentication** to **Workload Identity**. + + This hides the directory ID, application ID, and client secret fields, and the data source uses workload identity to authenticate to Azure Monitor Metrics and Logs, and Azure Resource Graph. + + {{< figure src="/media/docs/grafana/data-sources/screenshot-workload-identity.png" max-width="800px" class="docs-image--no-shadow" caption="Azure Monitor screenshot showing Workload Identity authentication" >}} + +3. There are additional configuration variables that can control the authentication method.`workload_identity_tenant_id` represents the Azure AD tenant that contains the managed identity, `workload_identity_client_id` represents the client ID of the managed identity if it differs from the default client ID, `workload_identity_token_file` represents the path to the token file. Refer to the [documentation](https://azure.github.io/azure-workload-identity/docs/) for more information on what values these variables should use, if any. + + ```ini + [azure] + workload_identity_enabled = true + workload_identity_tenant_id = IDENTITY_TENANT_ID + workload_identity_client_id = IDENTITY_CLIENT_ID + workload_identity_token_file = TOKEN_FILE_PATH + ``` ## Query the data source diff --git a/pkg/tsdb/azuremonitor/credentials.go b/pkg/tsdb/azuremonitor/credentials.go index 2e9f473d981..6e19f743f59 100644 --- a/pkg/tsdb/azuremonitor/credentials.go +++ b/pkg/tsdb/azuremonitor/credentials.go @@ -31,10 +31,14 @@ func getAuthType(cfg *setting.Cfg, jsonData *types.AzureClientSettings) string { return azcredentials.AzureAuthClientSecret } - // For newly created datasource with no configuration, managed identity is the default authentication type - // if they are enabled in Grafana config + // For newly created datasource with no configuration the order is as follows: + // Managed identity is the default if enabled + // Workload identity is the next option if enabled + // Client secret is the final fallback if cfg.Azure.ManagedIdentityEnabled { return azcredentials.AzureAuthManagedIdentity + } else if cfg.Azure.WorkloadIdentityEnabled { + return azcredentials.AzureAuthWorkloadIdentity } else { return azcredentials.AzureAuthClientSecret } @@ -84,8 +88,8 @@ func normalizeAzureCloud(cloudName string) (string, error) { func getAzureCloud(cfg *setting.Cfg, jsonData *types.AzureClientSettings) (string, error) { authType := getAuthType(cfg, jsonData) switch authType { - case azcredentials.AzureAuthManagedIdentity: - // In case of managed identity, the cloud is always same as where Grafana is hosted + case azcredentials.AzureAuthManagedIdentity, azcredentials.AzureAuthWorkloadIdentity: + // In case of managed identity and workload identity, the cloud is always same as where Grafana is hosted return getDefaultAzureCloud(cfg) case azcredentials.AzureAuthClientSecret: if cloud := jsonData.CloudName; cloud != "" { @@ -106,7 +110,9 @@ func getAzureCredentials(cfg *setting.Cfg, jsonData *types.AzureClientSettings, case azcredentials.AzureAuthManagedIdentity: credentials := &azcredentials.AzureManagedIdentityCredentials{} return credentials, nil - + case azcredentials.AzureAuthWorkloadIdentity: + credentials := &azcredentials.AzureWorkloadIdentityCredentials{} + return credentials, nil case azcredentials.AzureAuthClientSecret: cloud, err := getAzureCloud(cfg, jsonData) if err != nil { diff --git a/pkg/tsdb/azuremonitor/credentials_test.go b/pkg/tsdb/azuremonitor/credentials_test.go index 103c864cac6..58ea12660cd 100644 --- a/pkg/tsdb/azuremonitor/credentials_test.go +++ b/pkg/tsdb/azuremonitor/credentials_test.go @@ -77,6 +77,66 @@ func TestCredentials_getAuthType(t *testing.T) { assert.Equal(t, azcredentials.AzureAuthClientSecret, authType) }) }) + + t.Run("when workload identities enabled", func(t *testing.T) { + cfg.Azure.WorkloadIdentityEnabled = true + + t.Run("should be client secret if auth type is set to client secret", func(t *testing.T) { + jsonData := &types.AzureClientSettings{ + AzureAuthType: azcredentials.AzureAuthClientSecret, + } + + authType := getAuthType(cfg, jsonData) + + assert.Equal(t, azcredentials.AzureAuthClientSecret, authType) + }) + + t.Run("should be workload identity if datasource not configured and managed identity is disabled", func(t *testing.T) { + jsonData := &types.AzureClientSettings{ + AzureAuthType: "", + } + + authType := getAuthType(cfg, jsonData) + + assert.Equal(t, azcredentials.AzureAuthWorkloadIdentity, authType) + }) + + t.Run("should be client secret if auth type not specified but credentials configured", func(t *testing.T) { + jsonData := &types.AzureClientSettings{ + AzureAuthType: "", + TenantId: "9b9d90ee-a5cc-49c2-b97e-0d1b0f086b5c", + ClientId: "849ccbb0-92eb-4226-b228-ef391abd8fe6", + } + + authType := getAuthType(cfg, jsonData) + + assert.Equal(t, azcredentials.AzureAuthClientSecret, authType) + }) + }) + + t.Run("when workload identities disabled", func(t *testing.T) { + cfg.Azure.WorkloadIdentityEnabled = false + + t.Run("should be workload identity if auth type is set to workload identity", func(t *testing.T) { + jsonData := &types.AzureClientSettings{ + AzureAuthType: azcredentials.AzureAuthWorkloadIdentity, + } + + authType := getAuthType(cfg, jsonData) + + assert.Equal(t, azcredentials.AzureAuthWorkloadIdentity, authType) + }) + + t.Run("should be client secret if datasource not configured", func(t *testing.T) { + jsonData := &types.AzureClientSettings{ + AzureAuthType: "", + } + + authType := getAuthType(cfg, jsonData) + + assert.Equal(t, azcredentials.AzureAuthClientSecret, authType) + }) + }) } func TestCredentials_getAzureCloud(t *testing.T) { diff --git a/public/app/plugins/datasource/azuremonitor/components/AzureCredentialsForm.test.tsx b/public/app/plugins/datasource/azuremonitor/components/AzureCredentialsForm.test.tsx index a14308429e9..4fed620eb4b 100644 --- a/public/app/plugins/datasource/azuremonitor/components/AzureCredentialsForm.test.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/AzureCredentialsForm.test.tsx @@ -6,6 +6,7 @@ import AzureCredentialsForm, { Props } from './AzureCredentialsForm'; const setup = (propsFunc?: (props: Props) => Props) => { let props: Props = { managedIdentityEnabled: false, + workloadIdentityEnabled: false, credentials: { authType: 'clientsecret', azureCloud: 'azuremonitor', diff --git a/public/app/plugins/datasource/azuremonitor/components/AzureCredentialsForm.tsx b/public/app/plugins/datasource/azuremonitor/components/AzureCredentialsForm.tsx index 66240426676..4b3c32b3e29 100644 --- a/public/app/plugins/datasource/azuremonitor/components/AzureCredentialsForm.tsx +++ b/public/app/plugins/datasource/azuremonitor/components/AzureCredentialsForm.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent } from 'react'; +import React, { ChangeEvent, useMemo } from 'react'; import { SelectableValue } from '@grafana/data'; import { ConfigSection } from '@grafana/experimental'; @@ -9,39 +9,64 @@ import { AzureAuthType, AzureCredentials } from '../types'; export interface Props { managedIdentityEnabled: boolean; + workloadIdentityEnabled: boolean; credentials: AzureCredentials; azureCloudOptions?: SelectableValue[]; - onCredentialsChange?: (updatedCredentials: AzureCredentials) => void; + onCredentialsChange: (updatedCredentials: AzureCredentials) => void; disabled?: boolean; children?: JSX.Element; } -const authTypeOptions: Array> = [ - { - value: 'msi', - label: 'Managed Identity', - }, - { - value: 'clientsecret', - label: 'App Registration', - }, -]; - export const AzureCredentialsForm = (props: Props) => { - const { credentials, azureCloudOptions, onCredentialsChange, disabled, managedIdentityEnabled } = props; + const { + credentials, + azureCloudOptions, + onCredentialsChange, + disabled, + managedIdentityEnabled, + workloadIdentityEnabled, + } = props; + + const authTypeOptions = useMemo(() => { + let opts: Array> = [ + { + value: 'clientsecret', + label: 'App Registration', + }, + ]; + + if (managedIdentityEnabled) { + opts.push({ + value: 'msi', + label: 'Managed Identity', + }); + } + + if (workloadIdentityEnabled) { + opts.push({ + value: 'workloadidentity', + label: 'Workload Identity', + }); + } + + return opts; + }, [managedIdentityEnabled, workloadIdentityEnabled]); const onAuthTypeChange = (selected: SelectableValue) => { - if (onCredentialsChange) { - const updated: AzureCredentials = { - ...credentials, - authType: selected.value || 'msi', - }; - onCredentialsChange(updated); - } + const defaultAuthType = managedIdentityEnabled + ? 'msi' + : workloadIdentityEnabled + ? 'workloadidentity' + : 'clientsecret'; + const updated: AzureCredentials = { + ...credentials, + authType: selected.value || defaultAuthType, + }; + onCredentialsChange(updated); }; const onAzureCloudChange = (selected: SelectableValue) => { - if (onCredentialsChange && credentials.authType === 'clientsecret') { + if (credentials.authType === 'clientsecret') { const updated: AzureCredentials = { ...credentials, azureCloud: selected.value, @@ -51,7 +76,7 @@ export const AzureCredentialsForm = (props: Props) => { }; const onTenantIdChange = (event: ChangeEvent) => { - if (onCredentialsChange && credentials.authType === 'clientsecret') { + if (credentials.authType === 'clientsecret') { const updated: AzureCredentials = { ...credentials, tenantId: event.target.value, @@ -61,7 +86,7 @@ export const AzureCredentialsForm = (props: Props) => { }; const onClientIdChange = (event: ChangeEvent) => { - if (onCredentialsChange && credentials.authType === 'clientsecret') { + if (credentials.authType === 'clientsecret') { const updated: AzureCredentials = { ...credentials, clientId: event.target.value, @@ -71,7 +96,7 @@ export const AzureCredentialsForm = (props: Props) => { }; const onClientSecretChange = (event: ChangeEvent) => { - if (onCredentialsChange && credentials.authType === 'clientsecret') { + if (credentials.authType === 'clientsecret') { const updated: AzureCredentials = { ...credentials, clientSecret: event.target.value, @@ -81,7 +106,7 @@ export const AzureCredentialsForm = (props: Props) => { }; const onClientSecretReset = () => { - if (onCredentialsChange && credentials.authType === 'clientsecret') { + if (credentials.authType === 'clientsecret') { const updated: AzureCredentials = { ...credentials, clientSecret: '', @@ -92,7 +117,7 @@ export const AzureCredentialsForm = (props: Props) => { return ( - {managedIdentityEnabled && ( + {authTypeOptions.length > 1 && ( { <> {
Azure authentication
> = [ { value: AzureCloud.USGovernment, label: 'Azure US Government' }, ]; -export type AzureAuthType = 'msi' | 'clientsecret'; +export type AzureAuthType = 'msi' | 'clientsecret' | 'workloadidentity'; export type ConcealedSecret = symbol; @@ -26,6 +26,10 @@ export interface AzureManagedIdentityCredentials extends AzureCredentialsBase { authType: 'msi'; } +export interface AzureWorkloadIdentityCredentials extends AzureCredentialsBase { + authType: 'workloadidentity'; +} + export interface AzureClientSecretCredentials extends AzureCredentialsBase { authType: 'clientsecret'; azureCloud?: string; @@ -34,11 +38,15 @@ export interface AzureClientSecretCredentials extends AzureCredentialsBase { clientSecret?: string | ConcealedSecret; } -export type AzureCredentials = AzureManagedIdentityCredentials | AzureClientSecretCredentials; +export type AzureCredentials = + | AzureManagedIdentityCredentials + | AzureClientSecretCredentials + | AzureWorkloadIdentityCredentials; export function isCredentialsComplete(credentials: AzureCredentials): boolean { switch (credentials.authType) { case 'msi': + case 'workloadidentity': 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 index f48cf44b34d..67aabd352a3 100644 --- a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsConfig.ts +++ b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsConfig.ts @@ -42,12 +42,16 @@ export function getCredentials(options: DataSourceSettings): AzureCred switch (credentials.authType) { case 'msi': - if (config.azure.managedIdentityEnabled) { + case 'workloadidentity': + if ( + (credentials.authType === 'msi' && config.azure.managedIdentityEnabled) || + (credentials.authType === 'workloadidentity' && config.azure.workloadIdentityEnabled) + ) { return { - authType: 'msi', + authType: credentials.authType, }; } else { - // If authentication type is managed identity but managed identities were disabled in Grafana config, + // If authentication type is managed identity or workload identity but either method is disabled in Grafana config, // then we should fallback to an empty app registration (client secret) configuration return { authType: 'clientsecret', @@ -71,16 +75,21 @@ export function updateCredentials( ): DataSourceSettings { switch (credentials.authType) { case 'msi': - if (!config.azure.managedIdentityEnabled) { + case 'workloadidentity': + if (credentials.authType === 'msi' && !config.azure.managedIdentityEnabled) { throw new Error('Managed Identity authentication is not enabled in Grafana config.'); } + if (credentials.authType === 'workloadidentity' && !config.azure.workloadIdentityEnabled) { + throw new Error('Workload Identity authentication is not enabled in Grafana config.'); + } options = { ...options, jsonData: { ...options.jsonData, + azureAuthType: credentials.authType, azureCredentials: { - authType: 'msi', + authType: credentials.authType, }, }, }; diff --git a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.test.tsx b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.test.tsx index 384a5782064..ee4b0e3f6ec 100644 --- a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.test.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.test.tsx @@ -6,6 +6,7 @@ import AzureCredentialsForm, { Props } from './AzureCredentialsForm'; const setup = (propsFunc?: (props: Props) => Props) => { let props: Props = { managedIdentityEnabled: false, + workloadIdentityEnabled: false, credentials: { authType: 'clientsecret', azureCloud: 'azuremonitor', diff --git a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx index 18154fdbebc..0240671ec49 100644 --- a/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx +++ b/public/app/plugins/datasource/prometheus/configuration/AzureCredentialsForm.tsx @@ -1,5 +1,5 @@ import { cx } from '@emotion/css'; -import React, { ChangeEvent, useEffect, useReducer, useState } from 'react'; +import React, { ChangeEvent, useEffect, useMemo, useReducer, useState } from 'react'; import { SelectableValue } from '@grafana/data'; import { config } from '@grafana/runtime'; @@ -11,6 +11,7 @@ import { AzureAuthType, AzureCredentials, isCredentialsComplete } from './AzureC export interface Props { managedIdentityEnabled: boolean; + workloadIdentityEnabled: boolean; credentials: AzureCredentials; azureCloudOptions?: SelectableValue[]; onCredentialsChange: (updatedCredentials: AzureCredentials) => void; @@ -18,23 +19,45 @@ export interface Props { disabled?: boolean; } -const authTypeOptions: Array> = [ - { - value: 'msi', - label: 'Managed Identity', - }, - { - value: 'clientsecret', - label: 'App Registration', - }, -]; - export const AzureCredentialsForm = (props: Props) => { - const { credentials, azureCloudOptions, onCredentialsChange, getSubscriptions, disabled } = props; + const { + credentials, + azureCloudOptions, + onCredentialsChange, + getSubscriptions, + disabled, + managedIdentityEnabled, + workloadIdentityEnabled, + } = props; const hasRequiredFields = isCredentialsComplete(credentials); const [subscriptions, setSubscriptions] = useState>>([]); const [loadSubscriptionsClicked, onLoadSubscriptions] = useReducer((val) => val + 1, 0); + + const authTypeOptions = useMemo(() => { + let opts: Array> = [ + { + value: 'clientsecret', + label: 'App Registration', + }, + ]; + + if (managedIdentityEnabled) { + opts.push({ + value: 'msi', + label: 'Managed Identity', + }); + } + + if (workloadIdentityEnabled) { + opts.push({ + value: 'workloadidentity', + label: 'Workload Identity', + }); + } + return opts; + }, [managedIdentityEnabled, workloadIdentityEnabled]); + useEffect(() => { if (!getSubscriptions || !hasRequiredFields) { updateSubscriptions([]); @@ -70,19 +93,22 @@ export const AzureCredentialsForm = (props: Props) => { }; const onAuthTypeChange = (selected: SelectableValue) => { - if (onCredentialsChange) { - setSubscriptions([]); - const updated: AzureCredentials = { - ...credentials, - authType: selected.value || 'msi', - defaultSubscriptionId: undefined, - }; - onCredentialsChange(updated); - } + setSubscriptions([]); + const defaultAuthType = managedIdentityEnabled + ? 'msi' + : workloadIdentityEnabled + ? 'workloadidentity' + : 'clientsecret'; + const updated: AzureCredentials = { + ...credentials, + authType: selected.value || defaultAuthType, + defaultSubscriptionId: undefined, + }; + onCredentialsChange(updated); }; const onAzureCloudChange = (selected: SelectableValue) => { - if (onCredentialsChange && credentials.authType === 'clientsecret') { + if (credentials.authType === 'clientsecret') { setSubscriptions([]); const updated: AzureCredentials = { ...credentials, @@ -94,7 +120,7 @@ export const AzureCredentialsForm = (props: Props) => { }; const onTenantIdChange = (event: ChangeEvent) => { - if (onCredentialsChange && credentials.authType === 'clientsecret') { + if (credentials.authType === 'clientsecret') { setSubscriptions([]); const updated: AzureCredentials = { ...credentials, @@ -106,7 +132,7 @@ export const AzureCredentialsForm = (props: Props) => { }; const onClientIdChange = (event: ChangeEvent) => { - if (onCredentialsChange && credentials.authType === 'clientsecret') { + if (credentials.authType === 'clientsecret') { setSubscriptions([]); const updated: AzureCredentials = { ...credentials, @@ -118,7 +144,7 @@ export const AzureCredentialsForm = (props: Props) => { }; const onClientSecretChange = (event: ChangeEvent) => { - if (onCredentialsChange && credentials.authType === 'clientsecret') { + if (credentials.authType === 'clientsecret') { setSubscriptions([]); const updated: AzureCredentials = { ...credentials, @@ -130,7 +156,7 @@ export const AzureCredentialsForm = (props: Props) => { }; const onClientSecretReset = () => { - if (onCredentialsChange && credentials.authType === 'clientsecret') { + if (credentials.authType === 'clientsecret') { setSubscriptions([]); const updated: AzureCredentials = { ...credentials, @@ -142,19 +168,17 @@ export const AzureCredentialsForm = (props: Props) => { }; const onSubscriptionChange = (selected: SelectableValue | undefined) => { - if (onCredentialsChange) { - const updated: AzureCredentials = { - ...credentials, - defaultSubscriptionId: selected?.value, - }; - onCredentialsChange(updated); - } + const updated: AzureCredentials = { + ...credentials, + defaultSubscriptionId: selected?.value, + }; + onCredentialsChange(updated); }; const prometheusConfigOverhaulAuth = config.featureToggles.prometheusConfigOverhaulAuth; return (
- {props.managedIdentityEnabled && ( + {authTypeOptions.length > 1 && (