Alerting: Add OAuth2 Support for Webhook Receiver (#106302)

* Add to available channels

* Export

* Fix bug in deeply nested secrets

BE: Slice re-use bug when traversing deeply.

FE: Only at most one level of nesting was being taken into account
when determining secureFields keys. This change adds a new field on
NotificationChannelOption: secureFieldKey. This is populated on API GET via
transform. This change gives us the option to hardcode secureFieldKey in the
backend and no longer calculate the key via settings topology.

* Update grafana/alerting to 3e20fda3b872

* Prettier

* Linting

* Fix IntegrationConfig test to catch secure field mismatch
This commit is contained in:
Matthew Jacobson
2025-06-12 23:00:09 +02:00
committed by GitHub
parent 5135d5c87d
commit 0016b57486
16 changed files with 697 additions and 82 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ require (
github.com/googleapis/gax-go/v2 v2.14.1 // @grafana/grafana-backend-group
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad
github.com/grafana/alerting v0.0.0-20250605155607-02235095d018 // @grafana/alerting-backend
github.com/grafana/alerting v0.0.0-20250610043455-3e20fda3b872 // @grafana/alerting-backend
github.com/grafana/authlib v0.0.0-20250515162837-2f4a8263eabb // @grafana/identity-access-team
github.com/grafana/authlib/types v0.0.0-20250325095148-d6da9c164a7d // @grafana/identity-access-team
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
+2 -2
View File
@@ -1569,8 +1569,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grafana/alerting v0.0.0-20250605155607-02235095d018 h1:sGEBflMw3DUsfV4SCl1at+QRwGi5h29iiAuk8ZZn5Mw=
github.com/grafana/alerting v0.0.0-20250605155607-02235095d018/go.mod h1:e2nDocz4yTgLYGJFMrlL3DTjdMTEYPABorKc191b8/w=
github.com/grafana/alerting v0.0.0-20250610043455-3e20fda3b872 h1:bhUBPxMHtQB+AJaPcASElgwC7WFjWQvEiD3FoMEAv0E=
github.com/grafana/alerting v0.0.0-20250610043455-3e20fda3b872/go.mod h1:VANfU4LMnOR4Gv4hkCN9fGyQ/8Lf2DKOvzG70vI2IS4=
github.com/grafana/authlib v0.0.0-20250515162837-2f4a8263eabb h1:oTl2j6/4miQUYmXANp2pBuYCWA5f8NVYFfCWpczpFso=
github.com/grafana/authlib v0.0.0-20250515162837-2f4a8263eabb/go.mod h1:PBtQaXwkFu4BAt2aXsR7w8p8NVpdjV5aJYhqRDei9Us=
github.com/grafana/authlib/types v0.0.0-20250325095148-d6da9c164a7d h1:34E6btDAhdDOiSEyrMaYaHwnJpM8w9QKzVQZIBzLNmM=
@@ -324,6 +324,7 @@ type WebhookIntegration struct {
Message *string `json:"message,omitempty" yaml:"message,omitempty" hcl:"message"`
TLSConfig *TLSConfig `json:"tlsConfig,omitempty" yaml:"tlsConfig,omitempty" hcl:"tlsConfig,block"`
HMACConfig *HMACConfig `json:"hmacConfig,omitempty" yaml:"hmacConfig,omitempty" hcl:"hmacConfig,block"`
HTTPConfig *HTTPClientConfig `json:"http_config,omitempty" yaml:"http_config,omitempty" hcl:"http_config,block"`
Payload *CustomPayload `json:"payload,omitempty" yaml:"payload,omitempty" hcl:"payload,block"`
}
@@ -343,6 +344,41 @@ type HMACConfig struct {
TimestampHeader string `yaml:"timestampHeader,omitempty" json:"timestampHeader,omitempty" hcl:"timestamp_header"`
}
// HTTPClientConfig holds common configurations for notifier HTTP clients.
type HTTPClientConfig struct {
OAuth2Config *OAuth2Config `json:"oauth2,omitempty" yaml:"oauth2,omitempty" hcl:"oauth2,block"`
}
type ProxyConfig struct {
// ProxyURL is the HTTP proxy server to use to connect to the targets.
ProxyURL *string `yaml:"proxy_url,omitempty" json:"proxy_url,omitempty" hcl:"proxy_url"`
// NoProxy contains addresses that should not use a proxy.
NoProxy *string `yaml:"no_proxy,omitempty" json:"no_proxy,omitempty" hcl:"no_proxy"`
// ProxyFromEnvironment uses environment HTTP_PROXY, HTTPS_PROXY and NO_PROXY to determine proxies.
ProxyFromEnvironment *bool `yaml:"proxy_from_environment,omitempty" json:"proxy_from_environment,omitempty" hcl:"proxy_from_environment"`
// ProxyConnectHeader optionally specifies headers to send to proxies during CONNECT requests.
ProxyConnectHeader *map[string]string `yaml:"proxy_connect_header,omitempty" json:"proxy_connect_header,omitempty" hcl:"proxy_connect_header"`
}
type OAuth2Config struct {
// ClientID is the OAuth2 client ID.
ClientID string `json:"client_id" yaml:"client_id" hcl:"client_id"`
// ClientSecret is the OAuth2 client secret.
ClientSecret *Secret `json:"client_secret" yaml:"client_secret" hcl:"client_secret"`
// TokenURL is the URL to get the OAuth2 token.
TokenURL string `json:"token_url" yaml:"token_url" hcl:"token_url"`
// Scopes is the optional list of OAuth2 scopes.
Scopes *[]string `json:"scopes,omitempty" yaml:"scopes,omitempty" hcl:"scopes"`
// EndpointParams is the optional map of additional parameters to include in the token request.
EndpointParams *map[string]string `json:"endpoint_params,omitempty" yaml:"endpoint_params,omitempty" hcl:"endpoint_params"`
// TLSConfig is the optional TLS configuration to use for the OAuth2 token request.
TLSConfig *TLSConfig `json:"tls_config,omitempty" yaml:"tls_config,omitempty" hcl:"tls_config,block"`
// ProxyConfig is the optional proxy configuration to use for the OAuth2 token request.
ProxyConfig *ProxyConfig `json:"proxy_config,omitempty" yaml:"proxy_config,omitempty" hcl:"proxy_config,block"`
}
type WecomIntegration struct {
DisableResolveMessage *bool `json:"-" yaml:"-" hcl:"disable_resolve_message"`
+9 -4
View File
@@ -191,8 +191,12 @@ func (f IntegrationFieldPath) String() string {
return strings.Join(f, ".")
}
func (f IntegrationFieldPath) Append(segment string) IntegrationFieldPath {
return append(f, segment)
func (f IntegrationFieldPath) With(segment string) IntegrationFieldPath {
// Copy the existing path to avoid modifying the original slice.
newPath := make(IntegrationFieldPath, len(f)+1)
copy(newPath, f)
newPath[len(newPath)-1] = segment
return newPath
}
// IntegrationConfigFromType returns an integration configuration for a given integration type. If the integration type is
@@ -250,11 +254,12 @@ func (config *IntegrationConfig) GetSecretFields() []IntegrationFieldPath {
func traverseFields(flds map[string]IntegrationField, parentPath IntegrationFieldPath, predicate func(i IntegrationField) bool) []IntegrationFieldPath {
var result []IntegrationFieldPath
for key, field := range flds {
path := parentPath.With(key)
if predicate(field) {
result = append(result, parentPath.Append(key))
result = append(result, path)
}
if len(field.Fields) > 0 {
result = append(result, traverseFields(field.Fields, parentPath.Append(key), predicate)...)
result = append(result, traverseFields(field.Fields, path, predicate)...)
}
}
return result
@@ -244,11 +244,14 @@ func TestIntegrationConfig(t *testing.T) {
allSecrets[key] = struct{}{}
}
for field := range config.Fields {
_, isSecret := allSecrets[field]
assert.Equalf(t, isSecret, config.IsSecureField(NewIntegrationFieldPath(field)), "field '%s' is expected to be secret", field)
secretFields := config.GetSecretFields()
for _, path := range secretFields {
_, isSecret := allSecrets[path.String()]
assert.Equalf(t, isSecret, config.IsSecureField(path), "field '%s' is expected to be secret", path)
delete(allSecrets, path.String())
}
assert.False(t, config.IsSecureField(IntegrationFieldPath{"__--**unknown_field**--__"}))
assert.Empty(t, allSecrets, "mismatched secret fields for integration type %s: %v", integrationType, allSecrets)
})
}
@@ -113,6 +113,163 @@ func GetAvailableNotifiers() []*NotifierPlugin {
},
}
tlsSubformOptions := func() []NotifierOption {
return []NotifierOption{
{
Label: "Disable certificate verification",
Element: ElementTypeCheckbox,
Description: "Do not verify the server's certificate chain and host name.",
PropertyName: "insecureSkipVerify",
Required: false,
},
{
Label: "CA Certificate",
Element: ElementTypeTextArea,
Description: "Certificate in PEM format to use when verifying the server's certificate chain.",
InputType: InputTypeText,
PropertyName: "caCertificate",
Required: false,
Secure: true,
},
{
Label: "Client Certificate",
Element: ElementTypeTextArea,
Description: "Client certificate in PEM format to use when connecting to the server.",
InputType: InputTypeText,
PropertyName: "clientCertificate",
Required: false,
Secure: true,
},
{
Label: "Client Key",
Element: ElementTypeTextArea,
Description: "Client key in PEM format to use when connecting to the server.",
InputType: InputTypeText,
PropertyName: "clientKey",
Required: false,
Secure: true,
},
}
}
proxyOption := func() NotifierOption {
return NotifierOption{ // New in 12.1.
Label: "Proxy Config",
PropertyName: "proxy_config",
Description: "Optional proxy configuration.",
Element: ElementTypeSubform,
SubformOptions: []NotifierOption{
{
Label: "Proxy URL",
PropertyName: "proxy_url",
Description: "HTTP proxy server to use to connect to the targets.",
Element: ElementTypeInput,
InputType: InputTypeText,
Placeholder: "https://proxy.example.com",
Required: false,
Secure: false,
},
{
Label: "Proxy from environment",
PropertyName: "proxy_from_environment",
Description: "Use environment HTTP_PROXY, HTTPS_PROXY and NO_PROXY to determine proxies.",
Element: ElementTypeCheckbox,
Required: false,
Secure: false,
},
{
Label: "No Proxy",
PropertyName: "no_proxy",
Description: "Comma-separated list of addresses that should not use a proxy.",
Element: ElementTypeInput,
InputType: InputTypeText,
Placeholder: "example.com,1.2.3.4",
Required: false,
Secure: false,
},
{
Label: "Proxy Connect Header",
PropertyName: "proxy_connect_header",
Description: "Optional headers to send to proxies during CONNECT requests.",
Element: ElementTypeKeyValueMap,
InputType: InputTypeText,
Required: false,
Secure: false,
},
},
}
}
commonHttpClientOption := func() NotifierOption {
return NotifierOption{ // New in 12.1.
Label: "HTTP Config",
PropertyName: "http_config",
Description: "Common HTTP client options.",
Element: ElementTypeSubform,
SubformOptions: []NotifierOption{
{ // New in 12.1.
Label: "OAuth2",
PropertyName: "oauth2",
Description: "OAuth2 configuration options",
Element: ElementTypeSubform,
SubformOptions: []NotifierOption{
{
Label: "Token URL",
PropertyName: "token_url",
Element: ElementTypeInput,
Description: "URL for the access token endpoint.",
InputType: InputTypeText,
Required: true,
Secure: false,
},
{
Label: "Client ID",
PropertyName: "client_id",
Element: ElementTypeInput,
Description: "Client ID to use when authenticating.",
InputType: InputTypeText,
Required: true,
Secure: false,
},
{
Label: "Client Secret",
PropertyName: "client_secret",
Element: ElementTypeInput,
Description: "Client secret to use when authenticating.",
InputType: InputTypeText,
Required: true,
Secure: true,
},
{
Label: "Scopes",
PropertyName: "scopes",
Element: ElementStringArray,
Description: "Optional scopes to request when obtaining an access token.",
Required: false,
Secure: false,
},
{
Label: "Endpoint Parameters",
PropertyName: "endpoint_params",
Element: ElementTypeKeyValueMap,
Description: "Optional parameters to append to the access token request.",
Required: false,
Secure: false,
},
{
Label: "TLS",
PropertyName: "tls_config",
Description: "Optional TLS configuration options for OAuth2 requests.",
Element: ElementTypeSubform,
SubformOptions: tlsSubformOptions(),
},
proxyOption(),
},
},
},
}
}
return []*NotifierPlugin{
{
Type: "dingding",
@@ -1006,46 +1163,11 @@ func GetAvailableNotifiers() []*NotifierPlugin {
},
{
Label: "TLS",
PropertyName: "tlsConfig",
Description: "TLS configuration options",
Element: ElementTypeSubform,
SubformOptions: []NotifierOption{
{
Label: "Disable certificate verification",
Element: ElementTypeCheckbox,
Description: "Do not verify the server's certificate chain and host name.",
PropertyName: "insecureSkipVerify",
Required: false,
},
{
Label: "CA Certificate",
Element: ElementTypeTextArea,
Description: "Certificate in PEM format to use when verifying the server's certificate chain.",
InputType: InputTypeText,
PropertyName: "caCertificate",
Required: false,
Secure: true,
},
{
Label: "Client Certificate",
Element: ElementTypeTextArea,
Description: "Client certificate in PEM format to use when connecting to the server.",
InputType: InputTypeText,
PropertyName: "clientCertificate",
Required: false,
Secure: true,
},
{
Label: "Client Key",
Element: ElementTypeTextArea,
Description: "Client key in PEM format to use when connecting to the server.",
InputType: InputTypeText,
PropertyName: "clientKey",
Required: false,
Secure: true,
},
},
Label: "TLS",
PropertyName: "tlsConfig",
Description: "TLS configuration options",
Element: ElementTypeSubform,
SubformOptions: tlsSubformOptions(),
},
{
Label: "HMAC Signature",
@@ -1083,6 +1205,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
},
},
},
commonHttpClientOption(), // New in 12.1.
},
},
{
@@ -22,7 +22,18 @@ func TestGetSecretKeysForContactPointType(t *testing.T) {
{receiverType: "sensugo", expectedSecretFields: []string{"apikey"}},
{receiverType: "teams", expectedSecretFields: []string{}},
{receiverType: "telegram", expectedSecretFields: []string{"bottoken"}},
{receiverType: "webhook", expectedSecretFields: []string{"password", "authorization_credentials", "tlsConfig.caCertificate", "tlsConfig.clientCertificate", "tlsConfig.clientKey", "hmacConfig.secret"}},
{receiverType: "webhook", expectedSecretFields: []string{
"password",
"authorization_credentials",
"tlsConfig.caCertificate",
"tlsConfig.clientCertificate",
"tlsConfig.clientKey",
"hmacConfig.secret",
"http_config.oauth2.client_secret",
"http_config.oauth2.tls_config.caCertificate",
"http_config.oauth2.tls_config.clientCertificate",
"http_config.oauth2.tls_config.clientKey",
}},
{receiverType: "wecom", expectedSecretFields: []string{"url", "secret"}},
{receiverType: "prometheus-alertmanager", expectedSecretFields: []string{"basicAuthPassword"}},
{receiverType: "discord", expectedSecretFields: []string{"url"}},
@@ -14,7 +14,7 @@ import {
GrafanaAlertingConfiguration,
Matcher,
} from '../../../../plugins/datasource/alertmanager/types';
import { NotifierDTO } from '../../../../types';
import { NotificationChannelOption, NotifierDTO } from '../../../../types';
import { withPerformanceLogging } from '../Analytics';
import { matcherToMatcherField } from '../utils/alertmanager';
import {
@@ -106,6 +106,25 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
grafanaNotifiers: build.query<NotifierDTO[], void>({
query: () => ({ url: '/api/alert-notifiers' }),
transformResponse: (response: NotifierDTO[]) => {
const populateSecureFieldKey = (
option: NotificationChannelOption,
prefix: string
): NotificationChannelOption => ({
...option,
secureFieldKey: option.secure && !option.secureFieldKey ? `${prefix}${option.propertyName}` : undefined,
subformOptions: option.subformOptions?.map((suboption) =>
populateSecureFieldKey(suboption, `${prefix}${option.propertyName}.`)
),
});
return response.map((notifier) => ({
...notifier,
options: notifier.options.map((option) => {
return populateSecureFieldKey(option, '');
}),
}));
},
}),
// this endpoint requires administrator privileges
@@ -18,7 +18,7 @@ export interface Props<R extends ChannelValues> {
selectedChannelOptions: NotificationChannelOption[];
onResetSecureField: (key: string) => void;
onDeleteSubform?: (propertyName: string) => void;
onDeleteSubform?: (settingsPath: string, option: NotificationChannelOption) => void;
errors?: FieldErrors<R>;
/**
* The path for the integration in the array of integrations.
@@ -66,7 +66,7 @@ export function ChannelOptions<R extends ChannelValues>({
return null;
}
if (secureFields && secureFields[option.propertyName]) {
if (secureFields && secureFields[option.secureFieldKey ?? option.propertyName]) {
return (
<Field
key={key}
@@ -76,7 +76,7 @@ export function ChannelOptions<R extends ChannelValues>({
>
<SecretInput
id={`${settingsPath}${option.propertyName}`}
onReset={() => onResetSecureField(option.propertyName)}
onReset={() => onResetSecureField(option.secureFieldKey ?? option.propertyName)}
isConfigured
/>
</Field>
@@ -85,7 +85,7 @@ export function ChannelOptions<R extends ChannelValues>({
const error: FieldError | DeepMap<any, FieldError> | undefined = (
(option.secure ? errors?.secureFields : errors?.settings) as DeepMap<any, FieldError> | undefined
)?.[option.propertyName];
)?.[option.secureFieldKey ?? option.propertyName];
const defaultValue = defaultValues?.settings?.[option.propertyName];
@@ -122,6 +122,7 @@ const determineRequired = (
return option.required ? 'Required' : false;
}
// TODO: This doesn't work with nested secureFields.
const dependentOn = Boolean(settings[option.dependsOn]) || Boolean(secureFields[option.dependsOn]);
if (dependentOn) {
@@ -140,5 +141,6 @@ const determineReadOnly = (
return false;
}
// TODO: This doesn't work with nested secureFields.
return Boolean(settings[option.dependsOn]) || Boolean(secureFields[option.dependsOn]);
};
@@ -2,11 +2,12 @@ import { css } from '@emotion/css';
import { sortBy } from 'lodash';
import * as React from 'react';
import { useEffect, useMemo } from 'react';
import { Controller, FieldErrors, useFormContext, useWatch } from 'react-hook-form';
import { Controller, FieldErrors, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
import { NotificationChannelOption } from 'app/types';
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
import {
@@ -99,9 +100,6 @@ export function ChannelSubForm<R extends ChannelValues>({
return () => subscription.unsubscribe();
}, [selectedType, initialValues, setValue, settingsFieldPath, typeFieldPath, watch]);
// const [_secureFields, setSecureFields] = useState<Record<string, boolean | ''>>(secureFields ?? {});
const formSecureFields = useWatch({ control, name: `${channelFieldPath}.secureFields` });
const onResetSecureField = (key: string) => {
// formSecureFields might not be up to date if this function is called multiple times in a row
const currentSecureFields = getValues(`${channelFieldPath}.secureFields`);
@@ -110,12 +108,29 @@ export function ChannelSubForm<R extends ChannelValues>({
}
};
const onDeleteSubform = (propertyName: string) => {
const relatedSecureFields = Object.keys(formSecureFields).filter((key) => key.startsWith(propertyName));
const findSecureFieldsRecursively = (options: NotificationChannelOption[]): string[] => {
const secureFields: string[] = [];
options?.forEach((option) => {
if (option.secure && option.secureFieldKey) {
secureFields.push(option.secureFieldKey);
}
if (option.subformOptions) {
secureFields.push(...findSecureFieldsRecursively(option.subformOptions));
}
});
return secureFields;
};
const onDeleteSubform = (settingsPath: string, option: NotificationChannelOption) => {
// Get all subform options with secure=true recursively.
const relatedSecureFields = findSecureFieldsRecursively(option.subformOptions ?? []);
relatedSecureFields.forEach((key) => {
onResetSecureField(key);
});
setValue(`${channelFieldPath}.settings.${propertyName}`, undefined);
const fieldPath = settingsPath.startsWith(`${channelFieldPath}.settings.`)
? settingsPath.slice(`${channelFieldPath}.settings.`.length)
: settingsPath;
setValue(`${settingsFieldPath}.${fieldPath}`, undefined);
};
const typeOptions = useMemo(
@@ -65,12 +65,26 @@ const ui = {
webhook: {
url: byRole('textbox', { name: /^URL/ }),
tlsConfig: {
header: byRole('heading', { name: /TLS/ }),
caCertificate: byRole('textbox', { name: /^CA certificate/ }),
clientCert: byRole('textbox', { name: /^Client certificate/ }),
clientKey: byRole('textbox', { name: /^Client key/ }),
container: byTestId('items.0.settings.tlsConfig.container'),
caCertificate: byRole('textbox', { name: /^CA Certificate/ }),
clientCert: byRole('textbox', { name: /^Client Certificate/ }),
clientKey: byRole('textbox', { name: /^Client Key/ }),
deleteButton: byTestId('items.0.settings.tlsConfig.delete-button'),
},
httpConfig: {
container: byTestId('items.0.settings.http_config.container'),
oauth2: {
container: byTestId('items.0.settings.http_config.oauth2.container'),
clientSecret: byRole('textbox', { name: /^Client Secret/ }),
tls_config: {
container: byTestId('items.0.settings.http_config.oauth2.tls_config.container'),
caCertificate: byRole('textbox', { name: /^CA Certificate/ }),
clientCert: byRole('textbox', { name: /^Client Certificate/ }),
clientKey: byRole('textbox', { name: /^Client Key/ }),
deleteButton: byTestId('items.0.settings.http_config.oauth2.tls_config.delete-button'),
},
},
},
optionalSettings: byRole('button', { name: /optional webhook settings/i }),
},
};
@@ -416,6 +430,81 @@ describe('GrafanaReceiverForm', () => {
});
describe('Webhook contact point', () => {
it('should mark secure fields as configured when values exist', async () => {
const contactPointName = 'webhook-test';
const contactPoint = alertingFactory.alertmanager.grafana.contactPoint
.withIntegrations((integrationFactory) => [
integrationFactory
.webhook()
.params({
settings: {
url: 'http://example.com',
tlsConfig: {
insecureSkipVerify: false,
},
http_config: {
oauth2: {
client_id: 'client-id',
token_url: 'http://example.com/oauth2/token',
scopes: ['scope1', 'scope2'],
endpoint_params: {
param1: 'value1',
param2: 'value2',
},
tls_config: {
insecureSkipVerify: false,
},
proxy_config: {
proxy_url: 'http://example.com/proxy',
no_proxy: 'example.com',
proxy_from_environment: true,
proxy_connect_header: {
'X-Custom-Header': 'custom-value',
},
},
},
},
},
secureFields: {
'tlsConfig.caCertificate': true,
'tlsConfig.clientCertificate': true,
'tlsConfig.clientKey': true,
'http_config.oauth2.client_secret': true,
'http_config.oauth2.tls_config.caCertificate': true,
'http_config.oauth2.tls_config.clientCertificate': true,
'http_config.oauth2.tls_config.clientKey': true,
},
})
.build(),
])
.build({ id: 'webhook-id', name: contactPointName, metadata: { name: contactPointName } });
const { user } = renderWithProvider(<GrafanaReceiverForm contactPoint={contactPoint} editMode={true} />);
await waitFor(() => expect(ui.loadingIndicator.query()).not.toBeInTheDocument());
await waitFor(() => expect(ui.webhook.optionalSettings.query()).toBeInTheDocument());
await user.click(ui.webhook.optionalSettings.get());
const tlsContainer = await ui.webhook.tlsConfig.container.find();
const caCertField = ui.webhook.tlsConfig.caCertificate.get(tlsContainer);
const clientCertField = ui.webhook.tlsConfig.clientCert.get(tlsContainer);
const clientKeyField = ui.webhook.tlsConfig.clientKey.get(tlsContainer);
expect(caCertField).toHaveValue('configured');
expect(clientCertField).toHaveValue('configured');
expect(clientKeyField).toHaveValue('configured');
// Deeply nested secure fields.
const oauth2Container = await ui.webhook.httpConfig.oauth2.container.find();
const clientSecretField = ui.webhook.httpConfig.oauth2.clientSecret.get(oauth2Container);
const oauthCaCertField = ui.webhook.httpConfig.oauth2.tls_config.caCertificate.get(oauth2Container);
const oauthClientCertField = ui.webhook.httpConfig.oauth2.tls_config.clientCert.get(oauth2Container);
const oauthClientKeyField = ui.webhook.httpConfig.oauth2.tls_config.clientKey.get(oauth2Container);
expect(clientSecretField).toHaveValue('configured');
expect(oauthCaCertField).toHaveValue('configured');
expect(oauthClientCertField).toHaveValue('configured');
expect(oauthClientKeyField).toHaveValue('configured');
});
it('should properly remove TLS config when deleted', async () => {
const contactPointName = 'webhook-test';
const contactPoint = alertingFactory.alertmanager.grafana.contactPoint
@@ -431,6 +520,35 @@ describe('GrafanaReceiverForm', () => {
clientKey: 'client-key',
insecureSkipVerify: false,
},
http_config: {
oauth2: {
client_id: 'client-id',
token_url: 'http://example.com/oauth2/token',
scopes: ['scope1', 'scope2'],
endpoint_params: {
param1: 'value1',
param2: 'value2',
},
tls_config: {
// This tls config has existing values via secureFields, delete should remove this correctly as well.
insecureSkipVerify: false,
},
proxy_config: {
proxy_url: 'http://example.com/proxy',
no_proxy: 'example.com',
proxy_from_environment: true,
proxy_connect_header: {
'X-Custom-Header': 'custom-value',
},
},
},
},
},
secureFields: {
'http_config.oauth2.client_secret': true,
'http_config.oauth2.tls_config.caCertificate': true,
'http_config.oauth2.tls_config.clientCertificate': true,
'http_config.oauth2.tls_config.clientKey': true,
},
})
.build(),
@@ -448,9 +566,14 @@ describe('GrafanaReceiverForm', () => {
// Find and click the delete button next to TLS config
await user.click(ui.webhook.optionalSettings.get());
expect(await ui.webhook.tlsConfig.header.find(undefined)).toBeInTheDocument();
// Delete new tlsConfig values.
expect(await ui.webhook.tlsConfig.container.find()).toBeInTheDocument();
await user.click(await ui.webhook.tlsConfig.deleteButton.find());
// Delete existing oauth2 values.
expect(await ui.webhook.httpConfig.oauth2.tls_config.container.find()).toBeInTheDocument();
await user.click(await ui.webhook.httpConfig.oauth2.tls_config.deleteButton.find());
await user.click(ui.saveButton.get());
const requests = await capture;
@@ -467,6 +590,12 @@ describe('GrafanaReceiverForm', () => {
expect(integrationPayload.secureFields).not.toHaveProperty('tlsConfig.clientCert');
expect(integrationPayload.secureFields).not.toHaveProperty('tlsConfig.clientKey');
// Verify that OAuth2 TLS config is not present in the settings
expect(integrationPayload.settings).not.toHaveProperty('http_config.oauth2.tls_config');
expect(integrationPayload.secureFields).not.toHaveProperty('http_config.oauth2.tls_config.caCertificate');
expect(integrationPayload.secureFields).not.toHaveProperty('http_config.oauth2.tls_config.clientCert');
expect(integrationPayload.secureFields).not.toHaveProperty('http_config.oauth2.tls_config.clientKey');
expect(postRequestBody).toMatchSnapshot();
});
});
@@ -41,8 +41,32 @@ exports[`GrafanaReceiverForm Webhook contact point should properly remove TLS co
{
"disableResolveMessage": false,
"name": "webhook-test",
"secureFields": {},
"secureFields": {
"http_config.oauth2.client_secret": true,
},
"settings": {
"http_config": {
"oauth2": {
"client_id": "client-id",
"endpoint_params": {
"param1": "value1",
"param2": "value2",
},
"proxy_config": {
"no_proxy": "example.com",
"proxy_connect_header": {
"X-Custom-Header": "custom-value",
},
"proxy_from_environment": true,
"proxy_url": "http://example.com/proxy",
},
"scopes": [
"scope1",
"scope2",
],
"token_url": "http://example.com/oauth2/token",
},
},
"url": "http://example.com",
},
"type": "webhook",
@@ -26,21 +26,18 @@ interface Props {
defaultValue: any;
option: NotificationChannelOption;
getOptionMeta?: (option: NotificationChannelOption) => OptionMeta;
// this is defined if the option is rendered inside a subform
parentOption?: NotificationChannelOption;
invalid?: boolean;
pathPrefix: string;
error?: FieldError | DeepMap<any, FieldError>;
readOnly?: boolean;
customValidator?: (value: string) => boolean | string | Promise<boolean | string>;
onResetSecureField?: (propertyName: string) => void;
onDeleteSubform?: (propertyName: string) => void;
onDeleteSubform?: (settingsPath: string, option: NotificationChannelOption) => void;
secureFields: NotificationChannelSecureFields;
}
export const OptionField: FC<Props> = ({
option,
parentOption,
invalid,
pathPrefix,
error,
@@ -94,7 +91,6 @@ export const OptionField: FC<Props> = ({
invalid={invalid}
pathPrefix={pathPrefix}
readOnly={readOnly}
parentOption={parentOption}
customValidator={customValidator}
onResetSecureField={onResetSecureField}
secureFields={secureFields}
@@ -113,7 +109,6 @@ const OptionInput: FC<Props & { id: string }> = ({
customValidator,
onResetSecureField,
secureFields = {},
parentOption,
getOptionMeta,
}) => {
const styles = useStyles2(getStyles);
@@ -122,9 +117,9 @@ const OptionInput: FC<Props & { id: string }> = ({
const optionMeta = getOptionMeta?.(option);
const name = `${pathPrefix}${option.propertyName}`;
const nestedKey = parentOption ? `${parentOption.propertyName}.${option.propertyName}` : option.propertyName;
const isEncryptedInput = secureFields?.[nestedKey];
const secureFieldKey = option.secure && option.secureFieldKey ? option.secureFieldKey : '';
const isEncryptedInput = secureFieldKey && secureFields?.[secureFieldKey];
// workaround for https://github.com/react-hook-form/react-hook-form/issues/4993#issuecomment-829012506
useEffect(
@@ -162,7 +157,7 @@ const OptionInput: FC<Props & { id: string }> = ({
onSelectTemplate={onSelectTemplate}
>
{isEncryptedInput ? (
<SecretInput id={id} onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
<SecretInput id={id} onReset={() => onResetSecureField?.(secureFieldKey)} isConfigured />
) : (
<Input
id={id}
@@ -237,7 +232,7 @@ const OptionInput: FC<Props & { id: string }> = ({
onSelectTemplate={onSelectTemplate}
>
{isEncryptedInput ? (
<SecretTextArea id={id} onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
<SecretTextArea id={id} onReset={() => onResetSecureField?.(secureFieldKey)} isConfigured />
) : (
<TextArea
id={id}
@@ -22,7 +22,7 @@ interface Props {
* Callback function to delete a subform field. Removal requires side effects
* like settings and secure fields cleanup.
*/
onDelete?: (propertyName: string) => void;
onDelete?: (settingsPath: string, option: NotificationChannelOption) => void;
onResetSecureField?: (propertyName: string) => void;
}
@@ -46,7 +46,7 @@ export const SubformField = ({
const [show, setShow] = useState(!!value);
const onDeleteClick = () => {
onDelete?.(option.propertyName);
onDelete?.(name, option);
setShow(false);
};
@@ -71,9 +71,9 @@ export const SubformField = ({
readOnly={readOnly}
getOptionMeta={getOptionMeta}
onResetSecureField={onResetSecureField}
onDeleteSubform={onDelete}
secureFields={secureFields}
defaultValue={defaultValue?.[subOption.propertyName]}
parentOption={option}
key={subOption.propertyName}
option={subOption}
pathPrefix={`${name}.`}
@@ -1980,6 +1980,258 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
},
],
},
{
element: 'subform',
inputType: '',
label: 'HTTP Config',
description: 'Common HTTP client options.',
placeholder: '',
propertyName: 'http_config',
selectOptions: null,
showWhen: {
field: '',
is: '',
},
required: false,
validationRule: '',
secure: false,
dependsOn: '',
subformOptions: [
{
element: 'subform',
inputType: '',
label: 'OAuth2',
description: 'OAuth2 configuration options',
placeholder: '',
propertyName: 'oauth2',
selectOptions: null,
showWhen: {
field: '',
is: '',
},
required: false,
validationRule: '',
secure: false,
dependsOn: '',
subformOptions: [
{
element: 'input',
inputType: 'text',
label: 'Token URL',
description: 'URL for the access token endpoint.',
placeholder: '',
propertyName: 'token_url',
selectOptions: null,
showWhen: { field: '', is: '' },
required: true,
validationRule: '',
secure: false,
dependsOn: '',
},
{
element: 'input',
inputType: 'text',
label: 'Client ID',
description: 'Client ID to use when authenticating.',
placeholder: '',
propertyName: 'client_id',
selectOptions: null,
showWhen: { field: '', is: '' },
required: true,
validationRule: '',
secure: false,
dependsOn: '',
},
{
element: 'input',
inputType: 'text',
label: 'Client Secret',
description: 'Client secret to use when authenticating.',
placeholder: '',
propertyName: 'client_secret',
selectOptions: null,
showWhen: { field: '', is: '' },
required: true,
validationRule: '',
secure: true,
dependsOn: '',
},
{
element: 'string_array',
inputType: '',
label: 'Scopes',
description: 'Optional scopes to request when obtaining an access token.',
placeholder: '',
propertyName: 'scopes',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: false,
dependsOn: '',
},
{
element: 'key_value_map',
inputType: '',
label: 'Endpoint Parameters',
description: 'Optional parameters to append to the access token request.',
placeholder: '',
propertyName: 'endpoint_params',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: false,
dependsOn: '',
},
{
element: 'subform',
inputType: '',
label: 'TLS',
description: 'Optional TLS configuration options for OAuth2 requests.',
placeholder: '',
propertyName: 'tls_config',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: false,
dependsOn: '',
subformOptions: [
{
element: 'checkbox',
inputType: '',
label: 'Disable certificate verification',
description: "Do not verify the server's certificate chain and host name.",
placeholder: '',
propertyName: 'insecureSkipVerify',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: false,
dependsOn: '',
},
{
element: 'textarea',
inputType: 'text',
label: 'CA Certificate',
description: "Certificate in PEM format to use when verifying the server's certificate chain.",
placeholder: '',
propertyName: 'caCertificate',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: true,
dependsOn: '',
},
{
element: 'textarea',
inputType: 'text',
label: 'Client Certificate',
description: 'Client certificate in PEM format to use when connecting to the server.',
placeholder: '',
propertyName: 'clientCertificate',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: true,
dependsOn: '',
},
{
element: 'textarea',
inputType: 'text',
label: 'Client Key',
description: 'Client key in PEM format to use when connecting to the server.',
placeholder: '',
propertyName: 'clientKey',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: true,
dependsOn: '',
},
],
},
{
element: 'subform',
inputType: '',
label: 'Proxy Config',
description: 'Optional proxy configuration.',
placeholder: '',
propertyName: 'proxy_config',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: false,
dependsOn: '',
subformOptions: [
{
element: 'input',
inputType: 'text',
label: 'Proxy URL',
description: 'HTTP proxy server to use to connect to the targets.',
placeholder: 'https://proxy.example.com',
propertyName: 'proxy_url',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: false,
dependsOn: '',
},
{
element: 'checkbox',
inputType: '',
label: 'Proxy from environment',
description: 'Use environment HTTP_PROXY, HTTPS_PROXY and NO_PROXY to determine proxies.',
placeholder: '',
propertyName: 'proxy_from_environment',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: false,
dependsOn: '',
},
{
element: 'input',
inputType: 'text',
label: 'No Proxy',
description: 'Comma-separated list of addresses that should not use a proxy.',
placeholder: 'example.com,1.2.3.4',
propertyName: 'no_proxy',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: false,
dependsOn: '',
},
{
element: 'key_value_map',
inputType: 'text',
label: 'Proxy Connect Header',
description: 'Optional headers to send to proxies during CONNECT requests.',
placeholder: '',
propertyName: 'proxy_connect_header',
selectOptions: null,
showWhen: { field: '', is: '' },
required: false,
validationRule: '',
secure: false,
dependsOn: '',
},
],
},
],
},
],
},
],
},
oncall: {
+1
View File
@@ -148,6 +148,7 @@ export interface NotificationChannelOption {
propertyName: string;
required: boolean;
secure: boolean;
secureFieldKey?: string;
selectOptions?: Array<SelectableValue<string>> | null;
defaultValue?: SelectableValue<string>;
showWhen: { field: string; is: string | boolean };