MSSQL: Password auth for Azure AD (#89746)

* Password auth for Azure AD

* rename auth fields

* add azure flag for client password cred enabled

* prettier

* rename flag

* Update go.mod

* Update public/app/plugins/datasource/mssql/azureauth/AzureCredentialsForm.tsx

Co-authored-by: Andreas Christou <andreas.christou@grafana.com>

* Apply suggestions from code review

Co-authored-by: Andreas Christou <andreas.christou@grafana.com>

* update package

* go mod

* prettier

* remove password

* gowork

* remove unused env test

* linter

---------

Co-authored-by: Andreas Christou <andreas.christou@grafana.com>
This commit is contained in:
Andrew Hackmann 2024-07-16 12:08:51 -07:00 committed by GitHub
parent ac21fa8e18
commit 319a874033
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 215 additions and 42 deletions

View File

@ -997,6 +997,10 @@ username_assertion =
# By default this will include all Grafana Labs owned Azure plugins, or those that make use of Azure settings (Azure Monitor, Azure Data Explorer, Prometheus, MSSQL).
forward_settings_to_plugins = grafana-azure-monitor-datasource, prometheus, grafana-azure-data-explorer-datasource, mssql
# Specifies whether Entra password auth can be used for the MSSQL data source
# Disabled by default, needs to be explicitly enabled
azure_entra_password_credentials_enabled = false
#################################### Role-based Access Control ###########
[rbac]
# If enabled, cache permissions in a in memory cache

View File

@ -984,6 +984,10 @@
# By default this will include all Grafana Labs owned Azure plugins, or those that make use of Azure settings (Azure Monitor, Azure Data Explorer, Prometheus, MSSQL).
;forward_settings_to_plugins = grafana-azure-monitor-datasource, prometheus, grafana-azure-data-explorer-datasource, mssql
# Specifies whether Entra password auth can be used for the MSSQL data source
# Disabled by default, needs to be explicitly enabled
;azure_entra_password_credentials_enabled = false
#################################### Role-based Access Control ###########
[rbac]
;permission_cache = true

View File

@ -1275,6 +1275,12 @@ Set plugins that will receive Azure settings via plugin context.
By default, this will include all Grafana Labs owned Azure plugins or those that use Azure settings (Azure Monitor, Azure Data Explorer, Prometheus, MSSQL).
### azure_entra_password_credentials_enabled
Specifies whether Entra password auth can be used for the MSSQL data source. This authentication is not recommended and consideration should be taken before enabling this.
Disabled by default, needs to be explicitly enabled.
## [auth.jwt]
Refer to [JWT authentication]({{< relref "../configure-security/configure-authentication/jwt" >}}) for more information.

2
go.mod
View File

@ -88,7 +88,7 @@ require (
github.com/grafana/gofpdf v0.0.0-20231002120153-857cc45be447 // @grafana/sharing-squad
github.com/grafana/gomemcache v0.0.0-20240229205252-cd6a66d6fb56 // @grafana/grafana-operator-experience-squad
github.com/grafana/grafana-aws-sdk v0.28.0 // @grafana/aws-datasources
github.com/grafana/grafana-azure-sdk-go/v2 v2.0.4 // @grafana/partner-datasources
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.0 // @grafana/partner-datasources
github.com/grafana/grafana-cloud-migration-snapshot v1.1.0 // @grafana/grafana-operator-experience-squad
github.com/grafana/grafana-google-sdk-go v0.1.0 // @grafana/partner-datasources
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 // @grafana/grafana-backend-group

View File

@ -416,6 +416,8 @@ github.com/grafana/e2e v0.1.1-0.20221018202458-cffd2bb71c7b h1:Ha+kSIoTutf4ytlVw
github.com/grafana/e2e v0.1.1-0.20221018202458-cffd2bb71c7b/go.mod h1:3UsooRp7yW5/NJQBlXcTsAHOoykEhNUYXkQ3r6ehEEY=
github.com/grafana/e2e v0.1.1 h1:/b6xcv5BtoBnx8cZnCiey9DbjEc8z7gXHO5edoeRYxc=
github.com/grafana/e2e v0.1.1/go.mod h1:RpNLgae5VT+BUHvPE+/zSypmOXKwEu4t+tnEMS1ATaE=
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.0 h1:lajVqTWaE96MpbjZToj7EshvqgRWOfYNkD4MbIZizaY=
github.com/grafana/grafana-azure-sdk-go/v2 v2.1.0/go.mod h1:aKlFPE36IDa8qccRg3KbgZX3MQ5xymS3RelT4j6kkVU=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20240422145632-c33c6b5b6e6b h1:HCbWyVL6vi7gxyO76gQksSPH203oBJ1MJ3JcG1OQlsg=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20240422145632-c33c6b5b6e6b/go.mod h1:01sXtHoRwI8W324IPAzuxDFOmALqYLCOhvSC2fUHWXc=
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 h1:pdN6V1QBWetyv/0+wjACpqVH+eVULgEjkurDLq3goeM=

View File

@ -26,6 +26,7 @@ export interface AzureSettings {
workloadIdentityEnabled: boolean;
userIdentityEnabled: boolean;
userIdentityFallbackCredentialsEnabled: boolean;
azureEntraPasswordCredentialsEnabled: boolean;
}
export interface AzureCloudInfo {
@ -131,6 +132,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
workloadIdentityEnabled: false,
userIdentityEnabled: false,
userIdentityFallbackCredentialsEnabled: false,
azureEntraPasswordCredentialsEnabled: false,
};
caching = {
enabled: false,

View File

@ -66,12 +66,13 @@ type FrontendSettingsLicenseInfoDTO struct {
}
type FrontendSettingsAzureDTO struct {
Cloud string `json:"cloud"`
Clouds []azsettings.AzureCloudInfo `json:"clouds"`
ManagedIdentityEnabled bool `json:"managedIdentityEnabled"`
WorkloadIdentityEnabled bool `json:"workloadIdentityEnabled"`
UserIdentityEnabled bool `json:"userIdentityEnabled"`
UserIdentityFallbackCredentialsEnabled bool `json:"userIdentityFallbackCredentialsEnabled"`
Cloud string `json:"cloud,omitempty"`
Clouds []azsettings.AzureCloudInfo `json:"clouds,omitempty"`
ManagedIdentityEnabled bool `json:"managedIdentityEnabled,omitempty"`
WorkloadIdentityEnabled bool `json:"workloadIdentityEnabled,omitempty"`
UserIdentityEnabled bool `json:"userIdentityEnabled,omitempty"`
UserIdentityFallbackCredentialsEnabled bool `json:"userIdentityFallbackCredentialsEnabled,omitempty"`
AzureEntraPasswordCredentialsEnabled bool `json:"azureEntraPasswordCredentialsEnabled,omitempty"`
}
type FrontendSettingsCachingDTO struct {

View File

@ -276,6 +276,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
WorkloadIdentityEnabled: hs.Cfg.Azure.WorkloadIdentityEnabled,
UserIdentityEnabled: hs.Cfg.Azure.UserIdentityEnabled,
UserIdentityFallbackCredentialsEnabled: hs.Cfg.Azure.UserIdentityFallbackCredentialsEnabled,
AzureEntraPasswordCredentialsEnabled: hs.Cfg.Azure.AzureEntraPasswordCredentialsEnabled,
},
Caching: dtos.FrontendSettingsCachingDTO{

View File

@ -142,6 +142,8 @@ func (s *RequestConfigProvider) PluginRequestConfig(ctx context.Context, pluginI
}
}
}
m[azsettings.AzureEntraPasswordCredentialsEnabled] = strconv.FormatBool(azureSettings.AzureEntraPasswordCredentialsEnabled)
}
if s.cfg.UserFacingDefaultError != "" {

View File

@ -309,6 +309,7 @@ func TestRequestConfigProvider_PluginRequestConfig_azure(t *testing.T) {
},
UserIdentityFallbackCredentialsEnabled: true,
ForwardSettingsPlugins: []string{"grafana-azure-monitor-datasource", "prometheus", "grafana-azure-data-explorer-datasource", "mssql"},
AzureEntraPasswordCredentialsEnabled: true,
}
t.Run("uses the azure settings for an Azure plugin", func(t *testing.T) {
@ -389,6 +390,7 @@ func TestRequestConfigProvider_PluginRequestConfig_azure(t *testing.T) {
require.NotContains(t, m, "GFAZPL_USER_IDENTITY_CLIENT_ID")
require.NotContains(t, m, "GFAZPL_USER_IDENTITY_CLIENT_SECRET")
require.NotContains(t, m, "GFAZPL_USER_IDENTITY_ASSERTION")
require.NotContains(t, m, "GFAZPL_AZURE_ENTRA_PASSWORD_CREDENTIALS_ENABLED")
})
t.Run("uses the azure settings for a non-Azure user-specified plugin", func(t *testing.T) {
@ -413,6 +415,7 @@ func TestRequestConfigProvider_PluginRequestConfig_azure(t *testing.T) {
"GFAZPL_USER_IDENTITY_CLIENT_ID": "mock_user_identity_client_id",
"GFAZPL_USER_IDENTITY_CLIENT_SECRET": "mock_user_identity_client_secret",
"GFAZPL_USER_IDENTITY_ASSERTION": "username",
"GFAZPL_AZURE_ENTRA_PASSWORD_CREDENTIALS_ENABLED": "true",
})
})
}

View File

@ -80,5 +80,7 @@ func (cfg *Cfg) readAzureSettings() {
azureSettings.ForwardSettingsPlugins = util.SplitString(azureSection.Key("forward_settings_to_plugins").String())
azureSettings.AzureEntraPasswordCredentialsEnabled = azureSection.Key("azure_entra_password_credentials_enabled").MustBool(false)
cfg.Azure = azureSettings
}

View File

@ -321,6 +321,17 @@ func getAzureCredentialDSNFragment(azureCredentials azcredentials.AzureCredentia
c.ClientSecret,
"ActiveDirectoryApplication",
)
case *azcredentials.AzureEntraPasswordCredentials:
if cfg.Azure.AzureEntraPasswordCredentialsEnabled {
connStr += fmt.Sprintf("user id=%s;password=%s;applicationclientid=%s;fedauth=%s;",
c.UserId,
c.Password,
c.ClientId,
"ActiveDirectoryPassword",
)
} else {
return "", fmt.Errorf("azure entra password authentication is not enabled")
}
default:
return "", fmt.Errorf("unsupported azure authentication type")
}

View File

@ -9,6 +9,7 @@ export const configWithManagedIdentityEnabled: Partial<GrafanaBootConfig> = {
workloadIdentityEnabled: false,
userIdentityEnabled: false,
userIdentityFallbackCredentialsEnabled: false,
azureEntraPasswordCredentialsEnabled: false,
},
};
@ -19,6 +20,7 @@ export const configWithManagedIdentityDisabled: Partial<GrafanaBootConfig> = {
userIdentityEnabled: false,
cloud: 'AzureCloud',
userIdentityFallbackCredentialsEnabled: false,
azureEntraPasswordCredentialsEnabled: false,
},
};
@ -48,5 +50,5 @@ export const dataSourceSettingsWithClientSecretInSecureJSONData: Partial<
DataSourceSettings<AzureAuthJSONDataType, AzureAuthSecureJSONDataType>
> = {
...basicJSONData,
secureJsonData: { azureClientSecret: 'XXXX-super-secret-secret-XXXX' },
secureJsonData: { azureClientSecret: 'XXXX-super-secret-secret-XXXX', password: undefined },
};

View File

@ -13,6 +13,7 @@ import { AzureCredentialsForm } from './AzureCredentialsForm';
export const AzureAuthSettings = (props: HttpSettingsBaseProps) => {
const { dataSourceConfig: dsSettings, onChange } = props;
const managedIdentityEnabled = config.azure.managedIdentityEnabled;
const azureEntraPasswordCredentialsEnabled = config.azure.azureEntraPasswordCredentialsEnabled;
const credentials = useMemo(() => getCredentials(dsSettings, config), [dsSettings]);
@ -30,6 +31,7 @@ export const AzureAuthSettings = (props: HttpSettingsBaseProps) => {
return (
<AzureCredentialsForm
managedIdentityEnabled={managedIdentityEnabled}
azureEntraPasswordCredentialsEnabled={azureEntraPasswordCredentialsEnabled}
credentials={credentials}
azureCloudOptions={KnownAzureClouds}
onCredentialsChange={onCredentialsChange}

View File

@ -15,5 +15,7 @@ export function isCredentialsComplete(credentials: AzureCredentialsType): boolea
return true;
case AzureAuthType.CLIENT_SECRET:
return !!(credentials.azureCloud && credentials.tenantId && credentials.clientId && credentials.clientSecret);
case AzureAuthType.AD_PASSWORD:
return !!(credentials.clientId && credentials.password && credentials.userId);
}
}

View File

@ -19,15 +19,15 @@ export const getDefaultCredentials = (managedIdentityEnabled: boolean, cloud: st
};
export const getSecret = (
clientSecretStoredServerSide: boolean,
clientSecret: string | symbol | undefined
storedServerSide: boolean,
secret: string | symbol | undefined
): undefined | string | ConcealedSecretType => {
const concealedSecret: ConcealedSecretType = Symbol('Concealed client secret');
if (clientSecretStoredServerSide) {
if (storedServerSide) {
// The secret is concealed server side, so return the symbol
return concealedSecret;
} else {
return typeof clientSecret === 'string' && clientSecret.length > 0 ? clientSecret : undefined;
return typeof secret === 'string' && secret.length > 0 ? secret : undefined;
}
};
@ -41,6 +41,8 @@ export const getCredentials = (
// Secure JSON data/fields
const clientSecretStoredServerSide = dsSettings.secureJsonFields?.azureClientSecret;
const clientSecret = dsSettings.secureJsonData?.azureClientSecret;
const passwordStoredServerSide = dsSettings.secureJsonFields?.password;
const password = dsSettings.secureJsonData?.password;
// BootConfig data
const managedIdentityEnabled = !!bootConfig.azure?.managedIdentityEnabled;
@ -74,6 +76,13 @@ export const getCredentials = (
clientId: credentials.clientId,
clientSecret: getSecret(clientSecretStoredServerSide, clientSecret),
};
case AzureAuthType.AD_PASSWORD:
return {
authType: AzureAuthType.AD_PASSWORD,
userId: credentials.userId,
clientId: credentials.clientId,
password: getSecret(passwordStoredServerSide, password),
};
}
};
@ -130,5 +139,29 @@ export const updateCredentials = (
};
return dsSettings;
case AzureAuthType.AD_PASSWORD:
return {
...dsSettings,
jsonData: {
...dsSettings.jsonData,
azureCredentials: {
authType: AzureAuthType.AD_PASSWORD,
userId: credentials.userId,
clientId: credentials.clientId,
},
},
secureJsonData: {
...dsSettings.secureJsonData,
password:
typeof credentials.password === 'string' && credentials.password.length > 0
? credentials.password
: undefined,
},
secureJsonFields: {
...dsSettings.secureJsonFields,
password: typeof credentials.password === 'symbol',
},
};
}
};

View File

@ -7,25 +7,22 @@ import { AzureCredentialsType, AzureAuthType } from '../types';
export interface Props {
managedIdentityEnabled: boolean;
azureEntraPasswordCredentialsEnabled: boolean;
credentials: AzureCredentialsType;
azureCloudOptions?: SelectableValue[];
onCredentialsChange: (updatedCredentials: AzureCredentialsType) => void;
disabled?: boolean;
}
const authTypeOptions: Array<SelectableValue<AzureAuthType>> = [
{
value: AzureAuthType.MSI,
label: 'Managed Identity',
},
{
value: AzureAuthType.CLIENT_SECRET,
label: 'App Registration',
},
];
export const AzureCredentialsForm = (props: Props) => {
const { managedIdentityEnabled, credentials, azureCloudOptions, onCredentialsChange, disabled } = props;
const {
managedIdentityEnabled,
azureEntraPasswordCredentialsEnabled,
credentials,
azureCloudOptions,
onCredentialsChange,
disabled,
} = props;
const onAuthTypeChange = (selected: SelectableValue<AzureAuthType>) => {
if (onCredentialsChange) {
@ -37,8 +34,27 @@ export const AzureCredentialsForm = (props: Props) => {
}
};
const authTypeOptions: Array<SelectableValue<AzureAuthType>> = [
{
value: AzureAuthType.CLIENT_SECRET,
label: 'App Registration',
},
];
if (managedIdentityEnabled) {
authTypeOptions.push({
value: AzureAuthType.MSI,
label: 'Managed Identity',
});
}
if (azureEntraPasswordCredentialsEnabled) {
authTypeOptions.push({
value: AzureAuthType.AD_PASSWORD,
label: 'Azure Entra Password',
});
}
const onInputChange = ({ property, value }: { property: keyof AzureCredentialsType; value: string }) => {
if (onCredentialsChange && credentials.authType === 'clientsecret') {
if (onCredentialsChange) {
const updated: AzureCredentialsType = {
...credentials,
[property]: value,
@ -49,22 +65,20 @@ export const AzureCredentialsForm = (props: Props) => {
return (
<div>
{managedIdentityEnabled && (
<Field
label="Authentication"
description="Choose the type of authentication to Azure services"
htmlFor="authentication-type"
>
<Select
width={20}
value={authTypeOptions.find((opt) => opt.value === credentials.authType)}
options={authTypeOptions}
onChange={onAuthTypeChange}
disabled={disabled}
/>
</Field>
)}
{credentials.authType === 'clientsecret' && (
<Field
label="Authentication"
description="Choose the type of authentication to Azure services"
htmlFor="authentication-type"
>
<Select
width={20}
value={authTypeOptions.find((opt) => opt.value === credentials.authType)}
options={authTypeOptions}
onChange={onAuthTypeChange}
disabled={disabled}
/>
</Field>
{credentials.authType === AzureAuthType.CLIENT_SECRET && (
<>
{azureCloudOptions && (
<Field label="Azure Cloud" htmlFor="azure-cloud-type" disabled={disabled}>
@ -167,6 +181,84 @@ export const AzureCredentialsForm = (props: Props) => {
))}
</>
)}
{credentials.authType === AzureAuthType.AD_PASSWORD && azureEntraPasswordCredentialsEnabled && (
<>
<Field label="User Id" required htmlFor="user-id" invalid={!credentials.userId} error={'User ID is required'}>
<Input
width={45}
value={credentials.userId || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
onInputChange({ property: 'userId', value });
}}
disabled={disabled}
aria-label="User ID"
/>
</Field>
<Field
label="Application Client ID"
required
htmlFor="application-client-id"
invalid={!credentials.clientId}
error={'Application Client ID is required'}
>
<Input
width={45}
value={credentials.clientId || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
onInputChange({ property: 'clientId', value });
}}
disabled={disabled}
aria-label="Application Client ID"
/>
</Field>
{!disabled &&
(typeof credentials.password === 'symbol' ? (
<Field label="Password" htmlFor="password" required>
<div className="width-30" style={{ display: 'flex', gap: '4px' }}>
<Input
aria-label="Password"
placeholder="configured"
disabled={true}
data-testid={'password'}
width={45}
/>
<Button
variant="secondary"
type="button"
onClick={() => {
onInputChange({ property: 'password', value: '' });
}}
disabled={disabled}
>
Reset
</Button>
</div>
</Field>
) : (
<Field
label="Password"
required
htmlFor="password"
invalid={!credentials.password}
error={'Password is required'}
>
<Input
width={45}
aria-label="Password"
value={credentials.password || ''}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
onInputChange({ property: 'password', value });
}}
id="password"
disabled={disabled}
/>
</Field>
))}
</>
)}
</div>
);
};

View File

@ -108,7 +108,7 @@ export const ConfigurationEditor = (props: DataSourcePluginOptionsEditorProps<Ms
if (azureAuthIsSupported) {
return [
...basicAuthenticationOptions,
{ value: MSSQLAuthenticationType.azureAuth, label: 'Azure AD Authentication' },
{ value: MSSQLAuthenticationType.azureAuth, label: MSSQLAuthenticationType.azureAuth },
];
}

View File

@ -28,6 +28,7 @@ export type ConcealedSecretType = symbol;
export enum AzureAuthType {
MSI = 'msi',
CLIENT_SECRET = 'clientsecret',
AD_PASSWORD = 'ad-password',
}
export interface AzureCredentialsType {
@ -36,6 +37,8 @@ export interface AzureCredentialsType {
tenantId?: string;
clientId?: string;
clientSecret?: string | ConcealedSecretType;
userId?: string;
password?: string | ConcealedSecretType;
}
export interface MssqlOptions extends SQLOptions {
@ -63,6 +66,7 @@ export type AzureAuthJSONDataType = DataSourceJsonData & {
export type AzureAuthSecureJSONDataType = {
azureClientSecret: undefined | string | ConcealedSecretType;
password: undefined | string | ConcealedSecretType;
};
export type AzureAuthConfigType = {