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:
@@ -77,7 +77,7 @@ require (
|
|||||||
github.com/googleapis/gax-go/v2 v2.14.1 // @grafana/grafana-backend-group
|
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/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/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 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/authlib/types v0.0.0-20250325095148-d6da9c164a7d // @grafana/identity-access-team
|
||||||
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
|
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
|
||||||
|
|||||||
@@ -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/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 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
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-20250610043455-3e20fda3b872 h1:bhUBPxMHtQB+AJaPcASElgwC7WFjWQvEiD3FoMEAv0E=
|
||||||
github.com/grafana/alerting v0.0.0-20250605155607-02235095d018/go.mod h1:e2nDocz4yTgLYGJFMrlL3DTjdMTEYPABorKc191b8/w=
|
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 h1:oTl2j6/4miQUYmXANp2pBuYCWA5f8NVYFfCWpczpFso=
|
||||||
github.com/grafana/authlib v0.0.0-20250515162837-2f4a8263eabb/go.mod h1:PBtQaXwkFu4BAt2aXsR7w8p8NVpdjV5aJYhqRDei9Us=
|
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=
|
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"`
|
Message *string `json:"message,omitempty" yaml:"message,omitempty" hcl:"message"`
|
||||||
TLSConfig *TLSConfig `json:"tlsConfig,omitempty" yaml:"tlsConfig,omitempty" hcl:"tlsConfig,block"`
|
TLSConfig *TLSConfig `json:"tlsConfig,omitempty" yaml:"tlsConfig,omitempty" hcl:"tlsConfig,block"`
|
||||||
HMACConfig *HMACConfig `json:"hmacConfig,omitempty" yaml:"hmacConfig,omitempty" hcl:"hmacConfig,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"`
|
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"`
|
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 {
|
type WecomIntegration struct {
|
||||||
DisableResolveMessage *bool `json:"-" yaml:"-" hcl:"disable_resolve_message"`
|
DisableResolveMessage *bool `json:"-" yaml:"-" hcl:"disable_resolve_message"`
|
||||||
|
|
||||||
|
|||||||
@@ -191,8 +191,12 @@ func (f IntegrationFieldPath) String() string {
|
|||||||
return strings.Join(f, ".")
|
return strings.Join(f, ".")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f IntegrationFieldPath) Append(segment string) IntegrationFieldPath {
|
func (f IntegrationFieldPath) With(segment string) IntegrationFieldPath {
|
||||||
return append(f, segment)
|
// 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
|
// 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 {
|
func traverseFields(flds map[string]IntegrationField, parentPath IntegrationFieldPath, predicate func(i IntegrationField) bool) []IntegrationFieldPath {
|
||||||
var result []IntegrationFieldPath
|
var result []IntegrationFieldPath
|
||||||
for key, field := range flds {
|
for key, field := range flds {
|
||||||
|
path := parentPath.With(key)
|
||||||
if predicate(field) {
|
if predicate(field) {
|
||||||
result = append(result, parentPath.Append(key))
|
result = append(result, path)
|
||||||
}
|
}
|
||||||
if len(field.Fields) > 0 {
|
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
|
return result
|
||||||
|
|||||||
@@ -244,11 +244,14 @@ func TestIntegrationConfig(t *testing.T) {
|
|||||||
allSecrets[key] = struct{}{}
|
allSecrets[key] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
for field := range config.Fields {
|
secretFields := config.GetSecretFields()
|
||||||
_, isSecret := allSecrets[field]
|
for _, path := range secretFields {
|
||||||
assert.Equalf(t, isSecret, config.IsSecureField(NewIntegrationFieldPath(field)), "field '%s' is expected to be secret", field)
|
_, 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.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{
|
return []*NotifierPlugin{
|
||||||
{
|
{
|
||||||
Type: "dingding",
|
Type: "dingding",
|
||||||
@@ -1006,46 +1163,11 @@ func GetAvailableNotifiers() []*NotifierPlugin {
|
|||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
Label: "TLS",
|
Label: "TLS",
|
||||||
PropertyName: "tlsConfig",
|
PropertyName: "tlsConfig",
|
||||||
Description: "TLS configuration options",
|
Description: "TLS configuration options",
|
||||||
Element: ElementTypeSubform,
|
Element: ElementTypeSubform,
|
||||||
SubformOptions: []NotifierOption{
|
SubformOptions: tlsSubformOptions(),
|
||||||
{
|
|
||||||
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: "HMAC Signature",
|
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: "sensugo", expectedSecretFields: []string{"apikey"}},
|
||||||
{receiverType: "teams", expectedSecretFields: []string{}},
|
{receiverType: "teams", expectedSecretFields: []string{}},
|
||||||
{receiverType: "telegram", expectedSecretFields: []string{"bottoken"}},
|
{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: "wecom", expectedSecretFields: []string{"url", "secret"}},
|
||||||
{receiverType: "prometheus-alertmanager", expectedSecretFields: []string{"basicAuthPassword"}},
|
{receiverType: "prometheus-alertmanager", expectedSecretFields: []string{"basicAuthPassword"}},
|
||||||
{receiverType: "discord", expectedSecretFields: []string{"url"}},
|
{receiverType: "discord", expectedSecretFields: []string{"url"}},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
GrafanaAlertingConfiguration,
|
GrafanaAlertingConfiguration,
|
||||||
Matcher,
|
Matcher,
|
||||||
} from '../../../../plugins/datasource/alertmanager/types';
|
} from '../../../../plugins/datasource/alertmanager/types';
|
||||||
import { NotifierDTO } from '../../../../types';
|
import { NotificationChannelOption, NotifierDTO } from '../../../../types';
|
||||||
import { withPerformanceLogging } from '../Analytics';
|
import { withPerformanceLogging } from '../Analytics';
|
||||||
import { matcherToMatcherField } from '../utils/alertmanager';
|
import { matcherToMatcherField } from '../utils/alertmanager';
|
||||||
import {
|
import {
|
||||||
@@ -106,6 +106,25 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
|
|||||||
|
|
||||||
grafanaNotifiers: build.query<NotifierDTO[], void>({
|
grafanaNotifiers: build.query<NotifierDTO[], void>({
|
||||||
query: () => ({ url: '/api/alert-notifiers' }),
|
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
|
// this endpoint requires administrator privileges
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface Props<R extends ChannelValues> {
|
|||||||
selectedChannelOptions: NotificationChannelOption[];
|
selectedChannelOptions: NotificationChannelOption[];
|
||||||
|
|
||||||
onResetSecureField: (key: string) => void;
|
onResetSecureField: (key: string) => void;
|
||||||
onDeleteSubform?: (propertyName: string) => void;
|
onDeleteSubform?: (settingsPath: string, option: NotificationChannelOption) => void;
|
||||||
errors?: FieldErrors<R>;
|
errors?: FieldErrors<R>;
|
||||||
/**
|
/**
|
||||||
* The path for the integration in the array of integrations.
|
* The path for the integration in the array of integrations.
|
||||||
@@ -66,7 +66,7 @@ export function ChannelOptions<R extends ChannelValues>({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (secureFields && secureFields[option.propertyName]) {
|
if (secureFields && secureFields[option.secureFieldKey ?? option.propertyName]) {
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
key={key}
|
key={key}
|
||||||
@@ -76,7 +76,7 @@ export function ChannelOptions<R extends ChannelValues>({
|
|||||||
>
|
>
|
||||||
<SecretInput
|
<SecretInput
|
||||||
id={`${settingsPath}${option.propertyName}`}
|
id={`${settingsPath}${option.propertyName}`}
|
||||||
onReset={() => onResetSecureField(option.propertyName)}
|
onReset={() => onResetSecureField(option.secureFieldKey ?? option.propertyName)}
|
||||||
isConfigured
|
isConfigured
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -85,7 +85,7 @@ export function ChannelOptions<R extends ChannelValues>({
|
|||||||
|
|
||||||
const error: FieldError | DeepMap<any, FieldError> | undefined = (
|
const error: FieldError | DeepMap<any, FieldError> | undefined = (
|
||||||
(option.secure ? errors?.secureFields : errors?.settings) as 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];
|
const defaultValue = defaultValues?.settings?.[option.propertyName];
|
||||||
|
|
||||||
@@ -122,6 +122,7 @@ const determineRequired = (
|
|||||||
return option.required ? 'Required' : false;
|
return option.required ? 'Required' : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: This doesn't work with nested secureFields.
|
||||||
const dependentOn = Boolean(settings[option.dependsOn]) || Boolean(secureFields[option.dependsOn]);
|
const dependentOn = Boolean(settings[option.dependsOn]) || Boolean(secureFields[option.dependsOn]);
|
||||||
|
|
||||||
if (dependentOn) {
|
if (dependentOn) {
|
||||||
@@ -140,5 +141,6 @@ const determineReadOnly = (
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: This doesn't work with nested secureFields.
|
||||||
return Boolean(settings[option.dependsOn]) || Boolean(secureFields[option.dependsOn]);
|
return Boolean(settings[option.dependsOn]) || Boolean(secureFields[option.dependsOn]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import { css } from '@emotion/css';
|
|||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useEffect, useMemo } 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 { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||||
import { Trans, t } from '@grafana/i18n';
|
import { Trans, t } from '@grafana/i18n';
|
||||||
import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
|
import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
|
||||||
|
import { NotificationChannelOption } from 'app/types';
|
||||||
|
|
||||||
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
|
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
|
||||||
import {
|
import {
|
||||||
@@ -99,9 +100,6 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, [selectedType, initialValues, setValue, settingsFieldPath, typeFieldPath, watch]);
|
}, [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) => {
|
const onResetSecureField = (key: string) => {
|
||||||
// formSecureFields might not be up to date if this function is called multiple times in a row
|
// formSecureFields might not be up to date if this function is called multiple times in a row
|
||||||
const currentSecureFields = getValues(`${channelFieldPath}.secureFields`);
|
const currentSecureFields = getValues(`${channelFieldPath}.secureFields`);
|
||||||
@@ -110,12 +108,29 @@ export function ChannelSubForm<R extends ChannelValues>({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDeleteSubform = (propertyName: string) => {
|
const findSecureFieldsRecursively = (options: NotificationChannelOption[]): string[] => {
|
||||||
const relatedSecureFields = Object.keys(formSecureFields).filter((key) => key.startsWith(propertyName));
|
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) => {
|
relatedSecureFields.forEach((key) => {
|
||||||
onResetSecureField(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(
|
const typeOptions = useMemo(
|
||||||
|
|||||||
+134
-5
@@ -65,12 +65,26 @@ const ui = {
|
|||||||
webhook: {
|
webhook: {
|
||||||
url: byRole('textbox', { name: /^URL/ }),
|
url: byRole('textbox', { name: /^URL/ }),
|
||||||
tlsConfig: {
|
tlsConfig: {
|
||||||
header: byRole('heading', { name: /TLS/ }),
|
container: byTestId('items.0.settings.tlsConfig.container'),
|
||||||
caCertificate: byRole('textbox', { name: /^CA certificate/ }),
|
caCertificate: byRole('textbox', { name: /^CA Certificate/ }),
|
||||||
clientCert: byRole('textbox', { name: /^Client certificate/ }),
|
clientCert: byRole('textbox', { name: /^Client Certificate/ }),
|
||||||
clientKey: byRole('textbox', { name: /^Client key/ }),
|
clientKey: byRole('textbox', { name: /^Client Key/ }),
|
||||||
deleteButton: byTestId('items.0.settings.tlsConfig.delete-button'),
|
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 }),
|
optionalSettings: byRole('button', { name: /optional webhook settings/i }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -416,6 +430,81 @@ describe('GrafanaReceiverForm', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Webhook contact point', () => {
|
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 () => {
|
it('should properly remove TLS config when deleted', async () => {
|
||||||
const contactPointName = 'webhook-test';
|
const contactPointName = 'webhook-test';
|
||||||
const contactPoint = alertingFactory.alertmanager.grafana.contactPoint
|
const contactPoint = alertingFactory.alertmanager.grafana.contactPoint
|
||||||
@@ -431,6 +520,35 @@ describe('GrafanaReceiverForm', () => {
|
|||||||
clientKey: 'client-key',
|
clientKey: 'client-key',
|
||||||
insecureSkipVerify: false,
|
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(),
|
.build(),
|
||||||
@@ -448,9 +566,14 @@ describe('GrafanaReceiverForm', () => {
|
|||||||
// Find and click the delete button next to TLS config
|
// Find and click the delete button next to TLS config
|
||||||
await user.click(ui.webhook.optionalSettings.get());
|
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());
|
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());
|
await user.click(ui.saveButton.get());
|
||||||
|
|
||||||
const requests = await capture;
|
const requests = await capture;
|
||||||
@@ -467,6 +590,12 @@ describe('GrafanaReceiverForm', () => {
|
|||||||
expect(integrationPayload.secureFields).not.toHaveProperty('tlsConfig.clientCert');
|
expect(integrationPayload.secureFields).not.toHaveProperty('tlsConfig.clientCert');
|
||||||
expect(integrationPayload.secureFields).not.toHaveProperty('tlsConfig.clientKey');
|
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();
|
expect(postRequestBody).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-1
@@ -41,8 +41,32 @@ exports[`GrafanaReceiverForm Webhook contact point should properly remove TLS co
|
|||||||
{
|
{
|
||||||
"disableResolveMessage": false,
|
"disableResolveMessage": false,
|
||||||
"name": "webhook-test",
|
"name": "webhook-test",
|
||||||
"secureFields": {},
|
"secureFields": {
|
||||||
|
"http_config.oauth2.client_secret": true,
|
||||||
|
},
|
||||||
"settings": {
|
"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",
|
"url": "http://example.com",
|
||||||
},
|
},
|
||||||
"type": "webhook",
|
"type": "webhook",
|
||||||
|
|||||||
+5
-10
@@ -26,21 +26,18 @@ interface Props {
|
|||||||
defaultValue: any;
|
defaultValue: any;
|
||||||
option: NotificationChannelOption;
|
option: NotificationChannelOption;
|
||||||
getOptionMeta?: (option: NotificationChannelOption) => OptionMeta;
|
getOptionMeta?: (option: NotificationChannelOption) => OptionMeta;
|
||||||
// this is defined if the option is rendered inside a subform
|
|
||||||
parentOption?: NotificationChannelOption;
|
|
||||||
invalid?: boolean;
|
invalid?: boolean;
|
||||||
pathPrefix: string;
|
pathPrefix: string;
|
||||||
error?: FieldError | DeepMap<any, FieldError>;
|
error?: FieldError | DeepMap<any, FieldError>;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
customValidator?: (value: string) => boolean | string | Promise<boolean | string>;
|
customValidator?: (value: string) => boolean | string | Promise<boolean | string>;
|
||||||
onResetSecureField?: (propertyName: string) => void;
|
onResetSecureField?: (propertyName: string) => void;
|
||||||
onDeleteSubform?: (propertyName: string) => void;
|
onDeleteSubform?: (settingsPath: string, option: NotificationChannelOption) => void;
|
||||||
secureFields: NotificationChannelSecureFields;
|
secureFields: NotificationChannelSecureFields;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OptionField: FC<Props> = ({
|
export const OptionField: FC<Props> = ({
|
||||||
option,
|
option,
|
||||||
parentOption,
|
|
||||||
invalid,
|
invalid,
|
||||||
pathPrefix,
|
pathPrefix,
|
||||||
error,
|
error,
|
||||||
@@ -94,7 +91,6 @@ export const OptionField: FC<Props> = ({
|
|||||||
invalid={invalid}
|
invalid={invalid}
|
||||||
pathPrefix={pathPrefix}
|
pathPrefix={pathPrefix}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
parentOption={parentOption}
|
|
||||||
customValidator={customValidator}
|
customValidator={customValidator}
|
||||||
onResetSecureField={onResetSecureField}
|
onResetSecureField={onResetSecureField}
|
||||||
secureFields={secureFields}
|
secureFields={secureFields}
|
||||||
@@ -113,7 +109,6 @@ const OptionInput: FC<Props & { id: string }> = ({
|
|||||||
customValidator,
|
customValidator,
|
||||||
onResetSecureField,
|
onResetSecureField,
|
||||||
secureFields = {},
|
secureFields = {},
|
||||||
parentOption,
|
|
||||||
getOptionMeta,
|
getOptionMeta,
|
||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
@@ -122,9 +117,9 @@ const OptionInput: FC<Props & { id: string }> = ({
|
|||||||
const optionMeta = getOptionMeta?.(option);
|
const optionMeta = getOptionMeta?.(option);
|
||||||
|
|
||||||
const name = `${pathPrefix}${option.propertyName}`;
|
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
|
// workaround for https://github.com/react-hook-form/react-hook-form/issues/4993#issuecomment-829012506
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -162,7 +157,7 @@ const OptionInput: FC<Props & { id: string }> = ({
|
|||||||
onSelectTemplate={onSelectTemplate}
|
onSelectTemplate={onSelectTemplate}
|
||||||
>
|
>
|
||||||
{isEncryptedInput ? (
|
{isEncryptedInput ? (
|
||||||
<SecretInput id={id} onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
|
<SecretInput id={id} onReset={() => onResetSecureField?.(secureFieldKey)} isConfigured />
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
@@ -237,7 +232,7 @@ const OptionInput: FC<Props & { id: string }> = ({
|
|||||||
onSelectTemplate={onSelectTemplate}
|
onSelectTemplate={onSelectTemplate}
|
||||||
>
|
>
|
||||||
{isEncryptedInput ? (
|
{isEncryptedInput ? (
|
||||||
<SecretTextArea id={id} onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
|
<SecretTextArea id={id} onReset={() => onResetSecureField?.(secureFieldKey)} isConfigured />
|
||||||
) : (
|
) : (
|
||||||
<TextArea
|
<TextArea
|
||||||
id={id}
|
id={id}
|
||||||
|
|||||||
+3
-3
@@ -22,7 +22,7 @@ interface Props {
|
|||||||
* Callback function to delete a subform field. Removal requires side effects
|
* Callback function to delete a subform field. Removal requires side effects
|
||||||
* like settings and secure fields cleanup.
|
* like settings and secure fields cleanup.
|
||||||
*/
|
*/
|
||||||
onDelete?: (propertyName: string) => void;
|
onDelete?: (settingsPath: string, option: NotificationChannelOption) => void;
|
||||||
onResetSecureField?: (propertyName: string) => void;
|
onResetSecureField?: (propertyName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ export const SubformField = ({
|
|||||||
const [show, setShow] = useState(!!value);
|
const [show, setShow] = useState(!!value);
|
||||||
|
|
||||||
const onDeleteClick = () => {
|
const onDeleteClick = () => {
|
||||||
onDelete?.(option.propertyName);
|
onDelete?.(name, option);
|
||||||
setShow(false);
|
setShow(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,9 +71,9 @@ export const SubformField = ({
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
getOptionMeta={getOptionMeta}
|
getOptionMeta={getOptionMeta}
|
||||||
onResetSecureField={onResetSecureField}
|
onResetSecureField={onResetSecureField}
|
||||||
|
onDeleteSubform={onDelete}
|
||||||
secureFields={secureFields}
|
secureFields={secureFields}
|
||||||
defaultValue={defaultValue?.[subOption.propertyName]}
|
defaultValue={defaultValue?.[subOption.propertyName]}
|
||||||
parentOption={option}
|
|
||||||
key={subOption.propertyName}
|
key={subOption.propertyName}
|
||||||
option={subOption}
|
option={subOption}
|
||||||
pathPrefix={`${name}.`}
|
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: {
|
oncall: {
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export interface NotificationChannelOption {
|
|||||||
propertyName: string;
|
propertyName: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
secure: boolean;
|
secure: boolean;
|
||||||
|
secureFieldKey?: string;
|
||||||
selectOptions?: Array<SelectableValue<string>> | null;
|
selectOptions?: Array<SelectableValue<string>> | null;
|
||||||
defaultValue?: SelectableValue<string>;
|
defaultValue?: SelectableValue<string>;
|
||||||
showWhen: { field: string; is: string | boolean };
|
showWhen: { field: string; is: string | boolean };
|
||||||
|
|||||||
Reference in New Issue
Block a user