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
@@ -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"}},