Azure: Unify credentials in frontend for Prometheus (#96568)

init
This commit is contained in:
Younjin Song 2024-12-19 02:54:51 -08:00 committed by GitHub
parent 2303694293
commit 148289258f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 40 additions and 280 deletions

View File

@ -2,11 +2,11 @@ import { cx } from '@emotion/css';
import { FormEvent, useMemo, useState } from 'react'; import { FormEvent, useMemo, useState } from 'react';
import { useEffectOnce } from 'react-use'; import { useEffectOnce } from 'react-use';
import { AzureCredentials } from '@grafana/azure-sdk';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { InlineField, InlineFieldRow, InlineSwitch, Input } from '@grafana/ui'; import { InlineField, InlineFieldRow, InlineSwitch, Input } from '@grafana/ui';
import { HttpSettingsBaseProps } from '@grafana/ui/src/components/DataSourceSettings/types'; import { HttpSettingsBaseProps } from '@grafana/ui/src/components/DataSourceSettings/types';
import { AzureCredentials } from './AzureCredentials';
import { getAzureCloudOptions, getCredentials, updateCredentials } from './AzureCredentialsConfig'; import { getAzureCloudOptions, getCredentials, updateCredentials } from './AzureCredentialsConfig';
import { AzureCredentialsForm } from './AzureCredentialsForm'; import { AzureCredentialsForm } from './AzureCredentialsForm';

View File

@ -1,46 +0,0 @@
export enum AzureCloud {
Public = 'AzureCloud',
China = 'AzureChinaCloud',
USGovernment = 'AzureUSGovernment',
None = '',
}
export type AzureAuthType = 'msi' | 'clientsecret' | 'workloadidentity';
export type ConcealedSecret = symbol;
interface AzureCredentialsBase {
authType: AzureAuthType;
defaultSubscriptionId?: string;
}
export interface AzureManagedIdentityCredentials extends AzureCredentialsBase {
authType: 'msi';
}
export interface AzureWorkloadIdentityCredentials extends AzureCredentialsBase {
authType: 'workloadidentity';
}
export interface AzureClientSecretCredentials extends AzureCredentialsBase {
authType: 'clientsecret';
azureCloud?: string;
tenantId?: string;
clientId?: string;
clientSecret?: string | ConcealedSecret;
}
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);
}
}

View File

@ -1,29 +1,17 @@
import { getAzureClouds } from '@grafana/azure-sdk'; import {
AzureCredentials,
AzureDataSourceJsonData,
AzureDataSourceSecureJsonData,
AzureDataSourceSettings,
getAzureClouds,
getDatasourceCredentials,
getDefaultAzureCloud,
updateDatasourceCredentials,
} from '@grafana/azure-sdk';
import { DataSourceSettings, SelectableValue } from '@grafana/data'; import { DataSourceSettings, SelectableValue } from '@grafana/data';
import { PromOptions } from '@grafana/prometheus';
import { config } from '@grafana/runtime'; 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<any, any>): 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 hasCredentials(options: DataSourceSettings<any, any>): boolean {
return !!options.jsonData.azureCredentials;
}
export function getAzureCloudOptions(): Array<SelectableValue<string>> { export function getAzureCloudOptions(): Array<SelectableValue<string>> {
const cloudInfo = getAzureClouds(); const cloudInfo = getAzureClouds();
@ -41,101 +29,25 @@ export function getDefaultCredentials(): AzureCredentials {
} }
} }
export function getCredentials(options: DataSourceSettings<any, any>): AzureCredentials { export function getCredentials(options: AzureDataSourceSettings): AzureCredentials {
const credentials = options.jsonData.azureCredentials as AzureCredentials | undefined; const credentials = getDatasourceCredentials(options);
if (credentials) {
return credentials;
}
// If no credentials saved, then return empty credentials // If no credentials saved, then return empty credentials
// of type based on whether the managed identity enabled // of type based on whether the managed identity enabled
if (!credentials) { return getDefaultCredentials();
return getDefaultCredentials();
}
switch (credentials.authType) {
case 'msi':
case 'workloadidentity':
if (
(credentials.authType === 'msi' && config.azure.managedIdentityEnabled) ||
(credentials.authType === 'workloadidentity' && config.azure.workloadIdentityEnabled)
) {
return {
authType: credentials.authType,
};
} else {
// 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',
azureCloud: getDefaultAzureCloud(),
};
}
case 'clientsecret':
return {
authType: 'clientsecret',
azureCloud: credentials.azureCloud || getDefaultAzureCloud(),
tenantId: credentials.tenantId,
clientId: credentials.clientId,
clientSecret: getSecret(options),
};
}
} }
export function updateCredentials( export function updateCredentials(
options: DataSourceSettings<any, any>, options: AzurePromDataSourceSettings,
credentials: AzureCredentials credentials: AzureCredentials
): DataSourceSettings<any, any> { ): AzurePromDataSourceSettings {
switch (credentials.authType) { return updateDatasourceCredentials(options, credentials);
case 'msi':
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: credentials.authType,
},
},
};
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;
}
} }
export function setDefaultCredentials(options: DataSourceSettings<any, any>): Partial<DataSourceSettings<any, any>> { export function setDefaultCredentials(options: AzurePromDataSourceSettings): Partial<AzurePromDataSourceSettings> {
return { return {
jsonData: { jsonData: {
...options.jsonData, ...options.jsonData,
@ -144,13 +56,18 @@ export function setDefaultCredentials(options: DataSourceSettings<any, any>): Pa
}; };
} }
export function resetCredentials(options: DataSourceSettings<any, any>): Partial<DataSourceSettings<any, any>> { export function resetCredentials(options: AzurePromDataSourceSettings): Partial<AzurePromDataSourceSettings> {
return { return {
jsonData: { jsonData: {
...options.jsonData, ...options.jsonData,
azureAuth: undefined,
azureCredentials: undefined, azureCredentials: undefined,
azureEndpointResourceId: undefined, azureEndpointResourceId: undefined,
}, },
}; };
} }
export interface AzurePromDataSourceOptions extends PromOptions, AzureDataSourceJsonData {
azureEndpointResourceId?: string;
}
export type AzurePromDataSourceSettings = DataSourceSettings<AzurePromDataSourceOptions, AzureDataSourceSecureJsonData>;

View File

@ -12,7 +12,6 @@ const setup = (propsFunc?: (props: Props) => Props) => {
tenantId: 'e7f3f661-a933-3h3f-0294-31c4f962ec48', tenantId: 'e7f3f661-a933-3h3f-0294-31c4f962ec48',
clientId: '34509fad-c0r9-45df-9e25-f1ee34af6900', clientId: '34509fad-c0r9-45df-9e25-f1ee34af6900',
clientSecret: undefined, clientSecret: undefined,
defaultSubscriptionId: '44987801-6nn6-49he-9b2d-9106972f9789',
}, },
azureCloudOptions: [ azureCloudOptions: [
{ value: 'azuremonitor', label: 'Azure' }, { value: 'azuremonitor', label: 'Azure' },
@ -45,15 +44,4 @@ describe('AzureCredentialsForm', () => {
})); }));
expect(await screen.findByLabelText('Client Secret')).toBeDisabled(); expect(await screen.findByLabelText('Client Secret')).toBeDisabled();
}); });
it('should enable azure monitor load subscriptions button when all required fields are defined', async () => {
setup((props) => ({
...props,
credentials: {
...props.credentials,
clientSecret: 'e7f3f661-a933-4b3f-8176-51c4f982ec48',
},
}));
expect(await screen.findByRole('button', { name: 'Load Subscriptions' })).not.toBeDisabled();
});
}); });

View File

@ -1,12 +1,11 @@
import { cx } from '@emotion/css'; import { cx } from '@emotion/css';
import { ChangeEvent, useEffect, useMemo, useReducer, useState } from 'react'; import { ChangeEvent, useMemo } from 'react';
import { AzureAuthType, AzureCredentials } from '@grafana/azure-sdk';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { InlineFormLabel, Button, Select, Input } from '@grafana/ui'; import { InlineFormLabel, Button, Select, Input } from '@grafana/ui';
import { AzureAuthType, AzureCredentials, isCredentialsComplete } from './AzureCredentials';
export interface Props { export interface Props {
managedIdentityEnabled: boolean; managedIdentityEnabled: boolean;
workloadIdentityEnabled: boolean; workloadIdentityEnabled: boolean;
@ -22,15 +21,10 @@ export const AzureCredentialsForm = (props: Props) => {
credentials, credentials,
azureCloudOptions, azureCloudOptions,
onCredentialsChange, onCredentialsChange,
getSubscriptions,
disabled, disabled,
managedIdentityEnabled, managedIdentityEnabled,
workloadIdentityEnabled, workloadIdentityEnabled,
} = props; } = props;
const hasRequiredFields = isCredentialsComplete(credentials);
const [subscriptions, setSubscriptions] = useState<Array<SelectableValue<string>>>([]);
const [loadSubscriptionsClicked, onLoadSubscriptions] = useReducer((val) => val + 1, 0);
const authTypeOptions = useMemo(() => { const authTypeOptions = useMemo(() => {
let opts: Array<SelectableValue<AzureAuthType>> = [ let opts: Array<SelectableValue<AzureAuthType>> = [
@ -56,42 +50,7 @@ export const AzureCredentialsForm = (props: Props) => {
return opts; return opts;
}, [managedIdentityEnabled, workloadIdentityEnabled]); }, [managedIdentityEnabled, workloadIdentityEnabled]);
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<SelectableValue<string>>, 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<AzureAuthType>) => { const onAuthTypeChange = (selected: SelectableValue<AzureAuthType>) => {
setSubscriptions([]);
const defaultAuthType = managedIdentityEnabled const defaultAuthType = managedIdentityEnabled
? 'msi' ? 'msi'
: workloadIdentityEnabled : workloadIdentityEnabled
@ -100,18 +59,15 @@ export const AzureCredentialsForm = (props: Props) => {
const updated: AzureCredentials = { const updated: AzureCredentials = {
...credentials, ...credentials,
authType: selected.value || defaultAuthType, authType: selected.value || defaultAuthType,
defaultSubscriptionId: undefined,
}; };
onCredentialsChange(updated); onCredentialsChange(updated);
}; };
const onAzureCloudChange = (selected: SelectableValue<string>) => { const onAzureCloudChange = (selected: SelectableValue<string>) => {
if (credentials.authType === 'clientsecret') { if (credentials.authType === 'clientsecret') {
setSubscriptions([]);
const updated: AzureCredentials = { const updated: AzureCredentials = {
...credentials, ...credentials,
azureCloud: selected.value, azureCloud: selected.value,
defaultSubscriptionId: undefined,
}; };
onCredentialsChange(updated); onCredentialsChange(updated);
} }
@ -119,11 +75,9 @@ export const AzureCredentialsForm = (props: Props) => {
const onTenantIdChange = (event: ChangeEvent<HTMLInputElement>) => { const onTenantIdChange = (event: ChangeEvent<HTMLInputElement>) => {
if (credentials.authType === 'clientsecret') { if (credentials.authType === 'clientsecret') {
setSubscriptions([]);
const updated: AzureCredentials = { const updated: AzureCredentials = {
...credentials, ...credentials,
tenantId: event.target.value, tenantId: event.target.value,
defaultSubscriptionId: undefined,
}; };
onCredentialsChange(updated); onCredentialsChange(updated);
} }
@ -131,11 +85,9 @@ export const AzureCredentialsForm = (props: Props) => {
const onClientIdChange = (event: ChangeEvent<HTMLInputElement>) => { const onClientIdChange = (event: ChangeEvent<HTMLInputElement>) => {
if (credentials.authType === 'clientsecret') { if (credentials.authType === 'clientsecret') {
setSubscriptions([]);
const updated: AzureCredentials = { const updated: AzureCredentials = {
...credentials, ...credentials,
clientId: event.target.value, clientId: event.target.value,
defaultSubscriptionId: undefined,
}; };
onCredentialsChange(updated); onCredentialsChange(updated);
} }
@ -143,11 +95,9 @@ export const AzureCredentialsForm = (props: Props) => {
const onClientSecretChange = (event: ChangeEvent<HTMLInputElement>) => { const onClientSecretChange = (event: ChangeEvent<HTMLInputElement>) => {
if (credentials.authType === 'clientsecret') { if (credentials.authType === 'clientsecret') {
setSubscriptions([]);
const updated: AzureCredentials = { const updated: AzureCredentials = {
...credentials, ...credentials,
clientSecret: event.target.value, clientSecret: event.target.value,
defaultSubscriptionId: undefined,
}; };
onCredentialsChange(updated); onCredentialsChange(updated);
} }
@ -155,23 +105,14 @@ export const AzureCredentialsForm = (props: Props) => {
const onClientSecretReset = () => { const onClientSecretReset = () => {
if (credentials.authType === 'clientsecret') { if (credentials.authType === 'clientsecret') {
setSubscriptions([]);
const updated: AzureCredentials = { const updated: AzureCredentials = {
...credentials, ...credentials,
clientSecret: '', clientSecret: '',
defaultSubscriptionId: undefined,
}; };
onCredentialsChange(updated); onCredentialsChange(updated);
} }
}; };
const onSubscriptionChange = (selected: SelectableValue<string> | undefined) => {
const updated: AzureCredentials = {
...credentials,
defaultSubscriptionId: selected?.value,
};
onCredentialsChange(updated);
};
const prometheusConfigOverhaulAuth = config.featureToggles.prometheusConfigOverhaulAuth; const prometheusConfigOverhaulAuth = config.featureToggles.prometheusConfigOverhaulAuth;
return ( return (
@ -283,42 +224,6 @@ export const AzureCredentialsForm = (props: Props) => {
)} )}
</> </>
)} )}
{getSubscriptions && (
<>
<div className="gf-form-inline">
<div className="gf-form">
<InlineFormLabel className="width-12">Default Subscription</InlineFormLabel>
<div className={cx(prometheusConfigOverhaulAuth ? 'width-20' : 'width-25')}>
<Select
value={
credentials.defaultSubscriptionId
? subscriptions.find((opt) => opt.value === credentials.defaultSubscriptionId)
: undefined
}
options={subscriptions}
onChange={onSubscriptionChange}
isDisabled={disabled}
/>
</div>
</div>
</div>
<div className="gf-form-inline">
<div className="gf-form">
<div className="max-width-30 gf-form-inline">
<Button
variant="secondary"
size="sm"
type="button"
onClick={onLoadSubscriptions}
disabled={!hasRequiredFields}
>
Load Subscriptions
</Button>
</div>
</div>
</div>
</>
)}
</div> </div>
); );
}; };

View File

@ -1,14 +1,15 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { SIGV4ConnectionConfig } from '@grafana/aws-sdk'; import { SIGV4ConnectionConfig } from '@grafana/aws-sdk';
import { DataSourcePluginOptionsEditorProps, DataSourceSettings, GrafanaTheme2 } from '@grafana/data'; import { hasCredentials } from '@grafana/azure-sdk';
import { DataSourcePluginOptionsEditorProps, GrafanaTheme2 } from '@grafana/data';
import { AdvancedHttpSettings, ConfigSection, DataSourceDescription } from '@grafana/experimental'; import { AdvancedHttpSettings, ConfigSection, DataSourceDescription } from '@grafana/experimental';
import { AlertingSettingsOverhaul, PromOptions, PromSettings } from '@grafana/prometheus'; import { AlertingSettingsOverhaul, PromOptions, PromSettings } from '@grafana/prometheus';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { Alert, FieldValidationMessage, useTheme2 } from '@grafana/ui'; import { Alert, FieldValidationMessage, useTheme2 } from '@grafana/ui';
import { AzureAuthSettings } from './AzureAuthSettings'; import { AzureAuthSettings } from './AzureAuthSettings';
import { hasCredentials, setDefaultCredentials, resetCredentials } from './AzureCredentialsConfig'; import { AzurePromDataSourceSettings, setDefaultCredentials, resetCredentials } from './AzureCredentialsConfig';
import { DataSourcehttpSettingsOverhaul } from './DataSourceHttpSettingsOverhaulPackage'; import { DataSourcehttpSettingsOverhaul } from './DataSourceHttpSettingsOverhaulPackage';
export const PROM_CONFIG_LABEL_WIDTH = 30; export const PROM_CONFIG_LABEL_WIDTH = 30;
@ -20,8 +21,8 @@ export const ConfigEditor = (props: Props) => {
const azureAuthSettings = { const azureAuthSettings = {
azureAuthSupported: config.azureAuthEnabled, azureAuthSupported: config.azureAuthEnabled,
getAzureAuthEnabled: (config: DataSourceSettings): boolean => hasCredentials(config), getAzureAuthEnabled: (config: AzurePromDataSourceSettings): boolean => hasCredentials(config),
setAzureAuthEnabled: (config: DataSourceSettings, enabled: boolean) => setAzureAuthEnabled: (config: AzurePromDataSourceSettings, enabled: boolean) =>
enabled ? setDefaultCredentials(config) : resetCredentials(config), enabled ? setDefaultCredentials(config) : resetCredentials(config),
azureSettingsUI: AzureAuthSettings, azureSettingsUI: AzureAuthSettings,
}; };

View File

@ -1,22 +1,17 @@
import { ReactElement, useState } from 'react'; import { ReactElement, useState } from 'react';
import * as React from 'react'; import * as React from 'react';
import { DataSourceSettings } from '@grafana/data';
import { Auth, ConnectionSettings, convertLegacyAuthProps, AuthMethod } from '@grafana/experimental'; import { Auth, ConnectionSettings, convertLegacyAuthProps, AuthMethod } from '@grafana/experimental';
import { PromOptions, docsTip, overhaulStyles } from '@grafana/prometheus'; import { docsTip, overhaulStyles } from '@grafana/prometheus';
import { Alert, SecureSocksProxySettings, useTheme2 } from '@grafana/ui'; import { Alert, SecureSocksProxySettings, useTheme2 } from '@grafana/ui';
// NEED TO EXPORT THIS FROM GRAFANA/UI FOR EXTERNAL DS // NEED TO EXPORT THIS FROM GRAFANA/UI FOR EXTERNAL DS
import { AzureAuthSettings } from '@grafana/ui/src/components/DataSourceSettings/types'; import { AzureAuthSettings } from '@grafana/ui/src/components/DataSourceSettings/types';
import type { AzureCredentials } from './AzureCredentials'; import { AzurePromDataSourceSettings } from './AzureCredentialsConfig';
interface PromOptionsWithCloudAuth extends PromOptions {
azureCredentials?: AzureCredentials;
}
type Props = { type Props = {
options: DataSourceSettings<PromOptionsWithCloudAuth, {}>; options: AzurePromDataSourceSettings;
onOptionsChange: (options: DataSourceSettings<PromOptionsWithCloudAuth, {}>) => void; onOptionsChange: (options: AzurePromDataSourceSettings) => void;
azureAuthSettings: AzureAuthSettings; azureAuthSettings: AzureAuthSettings;
sigV4AuthToggleEnabled: boolean | undefined; sigV4AuthToggleEnabled: boolean | undefined;
renderSigV4Editor: React.ReactNode; renderSigV4Editor: React.ReactNode;