Alerting: Receiver API complete core implementation (#91738)
* Replace global authz abstraction with one compatible with uid scope * Replace GettableApiReceiver with models.Receiver in receiver_svc * GrafanaIntegrationConfig -> models.Integration * Implement Create/Update methods * Add optimistic concurrency to receiver API * Add scope to ReceiversRead & ReceiversReadSecrets migrates existing permissions to include implicit global scope * Add receiver create, update, delete actions * Check if receiver is used by rules before delete * On receiver name change update in routes and notification settings * Improve errors * Linting * Include read permissions are requirements for create/update/delete * Alias ngalert/models to ngmodels to differentiate from v0alpha1 model * Ensure integration UIDs are valid, unique, and generated if empty * Validate integration settings on create/update * Leverage UidToName to GetReceiver instead of GetReceivers * Remove some unnecessary uses of simplejson * alerting.notifications.receiver -> alerting.notifications.receivers * validator -> provenanceValidator * Only validate the modified receiver stops existing invalid receivers from preventing modification of a valid receiver. * Improve error in Integration.Encrypt * Remove scope from alert.notifications.receivers:create * Add todos for receiver renaming * Use receiverAC precondition checks in k8s api * Linting * Optional optimistic concurrency for delete * make update-workspace * More specific auth checks in k8s authorize.go * Add debug log when delete optimistic concurrency is skipped * Improve error message on authorizer.DecisionDeny * Keep error for non-forbidden errutil errors
This commit is contained in:
@@ -1,6 +1,21 @@
|
||||
package models
|
||||
|
||||
import "github.com/grafana/alerting/notify"
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"maps"
|
||||
"math"
|
||||
"sort"
|
||||
"unsafe"
|
||||
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||
)
|
||||
|
||||
// GetReceiverQuery represents a query for a single receiver.
|
||||
type GetReceiverQuery struct {
|
||||
@@ -30,15 +45,430 @@ type ListReceiversQuery struct {
|
||||
type Receiver struct {
|
||||
UID string
|
||||
Name string
|
||||
Integrations []*notify.GrafanaIntegrationConfig
|
||||
Integrations []*Integration
|
||||
Provenance Provenance
|
||||
Version string
|
||||
}
|
||||
|
||||
func (r *Receiver) Clone() Receiver {
|
||||
clone := Receiver{
|
||||
UID: r.UID,
|
||||
Name: r.Name,
|
||||
Provenance: r.Provenance,
|
||||
Version: r.Version,
|
||||
}
|
||||
|
||||
if r.Integrations != nil {
|
||||
clone.Integrations = make([]*Integration, len(r.Integrations))
|
||||
for i, integration := range r.Integrations {
|
||||
cloneIntegration := integration.Clone()
|
||||
clone.Integrations[i] = &cloneIntegration
|
||||
}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// Encrypt encrypts all integrations.
|
||||
func (r *Receiver) Encrypt(encryptFn EncryptFn) error {
|
||||
for _, integration := range r.Integrations {
|
||||
if err := integration.Encrypt(encryptFn); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts all integrations.
|
||||
func (r *Receiver) Decrypt(decryptFn DecryptFn) error {
|
||||
var errs []error
|
||||
for _, integration := range r.Integrations {
|
||||
if err := integration.Decrypt(decryptFn); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to decrypt integration %s: %w", integration.UID, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Redact redacts all integrations.
|
||||
func (r *Receiver) Redact(redactFn RedactFn) {
|
||||
for _, integration := range r.Integrations {
|
||||
integration.Redact(redactFn)
|
||||
}
|
||||
}
|
||||
|
||||
// WithExistingSecureFields copies secure settings from an existing receivers for each integration. Which fields to copy
|
||||
// is determined by the integrationSecureFields map, which contains a list of secure fields for each integration UID.
|
||||
func (r *Receiver) WithExistingSecureFields(existing *Receiver, integrationSecureFields map[string][]string) {
|
||||
existingIntegrations := make(map[string]*Integration, len(existing.Integrations))
|
||||
for _, integration := range existing.Integrations {
|
||||
existingIntegrations[integration.UID] = integration
|
||||
}
|
||||
|
||||
for _, integration := range r.Integrations {
|
||||
if integration.UID == "" {
|
||||
// This is a new integration, so we don't need to copy any secure fields.
|
||||
continue
|
||||
}
|
||||
fields := integrationSecureFields[integration.UID]
|
||||
if len(fields) > 0 {
|
||||
integration.WithExistingSecureFields(existingIntegrations[integration.UID], fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate validates all integration settings, ensuring that the integrations are correctly configured.
|
||||
func (r *Receiver) Validate(decryptFn DecryptFn) error {
|
||||
var errs []error
|
||||
for _, integration := range r.Integrations {
|
||||
if err := integration.Validate(decryptFn); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Integration is the domain model representation of an integration.
|
||||
type Integration struct {
|
||||
UID string
|
||||
Name string
|
||||
Config IntegrationConfig
|
||||
DisableResolveMessage bool
|
||||
// Settings can contain both secure and non-secure settings either unencrypted or redacted.
|
||||
Settings map[string]any
|
||||
// SecureSettings can contain only secure settings either encrypted or redacted.
|
||||
SecureSettings map[string]string
|
||||
}
|
||||
|
||||
// IntegrationConfig represents the configuration of an integration. It contains the type and information about the fields.
|
||||
type IntegrationConfig struct {
|
||||
Type string
|
||||
Fields map[string]IntegrationField
|
||||
}
|
||||
|
||||
// IntegrationField represents a field in an integration configuration.
|
||||
type IntegrationField struct {
|
||||
Name string
|
||||
Secure bool
|
||||
}
|
||||
|
||||
// IntegrationConfigFromType returns an integration configuration for a given integration type. If the integration type is
|
||||
// not found an error is returned.
|
||||
func IntegrationConfigFromType(integrationType string) (IntegrationConfig, error) {
|
||||
config, err := channels_config.ConfigForIntegrationType(integrationType)
|
||||
if err != nil {
|
||||
return IntegrationConfig{}, err
|
||||
}
|
||||
|
||||
integrationConfig := IntegrationConfig{Type: config.Type, Fields: make(map[string]IntegrationField, len(config.Options))}
|
||||
for _, option := range config.Options {
|
||||
integrationConfig.Fields[option.PropertyName] = IntegrationField{
|
||||
Name: option.PropertyName,
|
||||
Secure: option.Secure,
|
||||
}
|
||||
}
|
||||
return integrationConfig, nil
|
||||
}
|
||||
|
||||
// IsSecureField returns true if the field is both known and marked as secure in the integration configuration.
|
||||
func (config *IntegrationConfig) IsSecureField(field string) bool {
|
||||
if config.Fields != nil {
|
||||
if f, ok := config.Fields[field]; ok {
|
||||
return f.Secure
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (config *IntegrationConfig) Clone() IntegrationConfig {
|
||||
clone := IntegrationConfig{
|
||||
Type: config.Type,
|
||||
}
|
||||
|
||||
if len(config.Fields) > 0 {
|
||||
clone.Fields = make(map[string]IntegrationField, len(config.Fields))
|
||||
for key, field := range config.Fields {
|
||||
clone.Fields[key] = field.Clone()
|
||||
}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func (field *IntegrationField) Clone() IntegrationField {
|
||||
return IntegrationField{
|
||||
Name: field.Name,
|
||||
Secure: field.Secure,
|
||||
}
|
||||
}
|
||||
|
||||
func (integration *Integration) Clone() Integration {
|
||||
return Integration{
|
||||
UID: integration.UID,
|
||||
Name: integration.Name,
|
||||
Config: integration.Config.Clone(),
|
||||
DisableResolveMessage: integration.DisableResolveMessage,
|
||||
Settings: maps.Clone(integration.Settings),
|
||||
SecureSettings: maps.Clone(integration.SecureSettings),
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt encrypts all fields in Settings that are marked as secure in the integration configuration. The encrypted values
|
||||
// are stored in SecureSettings and the original values are removed from Settings.
|
||||
// If a field is already in SecureSettings it is not encrypted again.
|
||||
func (integration *Integration) Encrypt(encryptFn EncryptFn) error {
|
||||
var errs []error
|
||||
for key, val := range integration.Settings {
|
||||
if isSecureField := integration.Config.IsSecureField(key); !isSecureField {
|
||||
continue
|
||||
}
|
||||
|
||||
delete(integration.Settings, key)
|
||||
unencryptedSecureValue, isString := val.(string)
|
||||
if !isString {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := integration.SecureSettings[key]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
encrypted, err := encryptFn(unencryptedSecureValue)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to encrypt secure setting '%s': %w", key, err))
|
||||
}
|
||||
|
||||
integration.SecureSettings[key] = encrypted
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Decrypt decrypts all fields in SecureSettings and moves them to Settings.
|
||||
// The original values are removed from SecureSettings.
|
||||
func (integration *Integration) Decrypt(decryptFn DecryptFn) error {
|
||||
var errs []error
|
||||
for key, secureVal := range integration.SecureSettings {
|
||||
decrypted, err := decryptFn(secureVal)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to decrypt secure setting '%s': %w", key, err))
|
||||
}
|
||||
delete(integration.SecureSettings, key)
|
||||
integration.Settings[key] = decrypted
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Redact redacts all fields in SecureSettings and moves them to Settings.
|
||||
// The original values are removed from SecureSettings.
|
||||
func (integration *Integration) Redact(redactFn RedactFn) {
|
||||
for key, secureVal := range integration.SecureSettings { // TODO: Should we trust that the receiver is stored correctly or use known secure settings?
|
||||
integration.Settings[key] = redactFn(secureVal)
|
||||
delete(integration.SecureSettings, key)
|
||||
}
|
||||
|
||||
// We don't trust that the receiver is stored correctly, so we redact secure fields in the settings as well.
|
||||
for key, val := range integration.Settings {
|
||||
if val != "" && integration.Config.IsSecureField(key) {
|
||||
s, isString := val.(string)
|
||||
if !isString {
|
||||
continue
|
||||
}
|
||||
integration.Settings[key] = redactFn(s)
|
||||
delete(integration.SecureSettings, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithExistingSecureFields copies secure settings from an existing integration. Which fields to copy is determined by the
|
||||
// fields slice.
|
||||
// Any fields found in Settings or SecureSettings are removed, even if they don't appear in the existing integration.
|
||||
func (integration *Integration) WithExistingSecureFields(existing *Integration, fields []string) {
|
||||
// Now for each field marked as secure, we copy the value from the existing receiver.
|
||||
for _, secureField := range fields {
|
||||
delete(integration.Settings, secureField) // Ensure secure fields are removed from new settings and secure settings.
|
||||
delete(integration.SecureSettings, secureField)
|
||||
if existing != nil {
|
||||
if existingVal, ok := existing.SecureSettings[secureField]; ok {
|
||||
integration.SecureSettings[secureField] = existingVal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SecureFields returns a map of all secure fields in the integration. This includes fields in SecureSettings and fields
|
||||
// in Settings that are marked as secure in the integration configuration.
|
||||
func (integration *Integration) SecureFields() map[string]bool {
|
||||
secureFields := make(map[string]bool, len(integration.SecureSettings))
|
||||
if len(integration.SecureSettings) > 0 {
|
||||
for key := range integration.SecureSettings {
|
||||
secureFields[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
// We mark secure fields in the settings as well. This is to ensure legacy behaviour for redacted secure settings.
|
||||
for key, val := range integration.Settings {
|
||||
if val != "" && integration.Config.IsSecureField(key) {
|
||||
secureFields[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
return secureFields
|
||||
}
|
||||
|
||||
// Validate validates the integration settings, ensuring that the integration is correctly configured.
|
||||
func (integration *Integration) Validate(decryptFn DecryptFn) error {
|
||||
decrypted := integration.Clone()
|
||||
if err := decrypted.Decrypt(decryptFn); err != nil {
|
||||
return err
|
||||
}
|
||||
jsonBytes, err := json.Marshal(decrypted.Settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ValidateIntegration(context.Background(), alertingNotify.GrafanaIntegrationConfig{
|
||||
UID: decrypted.UID,
|
||||
Name: decrypted.Name,
|
||||
Type: decrypted.Config.Type,
|
||||
DisableResolveMessage: decrypted.DisableResolveMessage,
|
||||
Settings: jsonBytes,
|
||||
SecureSettings: decrypted.SecureSettings,
|
||||
}, alertingNotify.NoopDecrypt)
|
||||
}
|
||||
|
||||
func ValidateIntegration(ctx context.Context, integration alertingNotify.GrafanaIntegrationConfig, decryptFunc alertingNotify.GetDecryptedValueFn) error {
|
||||
if integration.Type == "" {
|
||||
return fmt.Errorf("type should not be an empty string")
|
||||
}
|
||||
if integration.Settings == nil {
|
||||
return fmt.Errorf("settings should not be empty")
|
||||
}
|
||||
|
||||
_, err := alertingNotify.BuildReceiverConfiguration(ctx, &alertingNotify.APIReceiver{
|
||||
GrafanaIntegrations: alertingNotify.GrafanaIntegrations{
|
||||
Integrations: []*alertingNotify.GrafanaIntegrationConfig{&integration},
|
||||
},
|
||||
}, decryptFunc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type EncryptFn = func(string) (string, error)
|
||||
type DecryptFn = func(string) (string, error)
|
||||
type RedactFn = func(string) string
|
||||
|
||||
// Identified describes a class of resources that have a UID. Created to abstract required fields for authorization.
|
||||
type Identified interface {
|
||||
GetUID() string
|
||||
}
|
||||
|
||||
func (r Receiver) GetUID() string {
|
||||
func (r *Receiver) GetUID() string {
|
||||
return r.UID
|
||||
}
|
||||
|
||||
func (r *Receiver) Fingerprint() string {
|
||||
sum := fnv.New64()
|
||||
|
||||
writeBytes := func(b []byte) {
|
||||
_, _ = sum.Write(b)
|
||||
// add a byte sequence that cannot happen in UTF-8 strings.
|
||||
_, _ = sum.Write([]byte{255})
|
||||
}
|
||||
writeString := func(s string) {
|
||||
if len(s) == 0 {
|
||||
writeBytes(nil)
|
||||
return
|
||||
}
|
||||
// #nosec G103
|
||||
// avoid allocation when converting string to byte slice
|
||||
writeBytes(unsafe.Slice(unsafe.StringData(s), len(s)))
|
||||
}
|
||||
// this temp slice is used to convert ints to bytes.
|
||||
tmp := make([]byte, 8)
|
||||
writeInt := func(u int) {
|
||||
binary.LittleEndian.PutUint64(tmp, uint64(u))
|
||||
writeBytes(tmp)
|
||||
}
|
||||
|
||||
writeIntegration := func(in *Integration) {
|
||||
writeString(in.UID)
|
||||
writeString(in.Name)
|
||||
|
||||
// Do not include fields in fingerprint as these are not part of the receiver definition.
|
||||
writeString(in.Config.Type)
|
||||
|
||||
if in.DisableResolveMessage {
|
||||
writeInt(1)
|
||||
} else {
|
||||
writeInt(0)
|
||||
}
|
||||
|
||||
// allocate a slice that will be used for sorting keys, so we allocate it only once
|
||||
var keys []string
|
||||
maxLen := int(math.Max(float64(len(in.Settings)), float64(len(in.SecureSettings))))
|
||||
if maxLen > 0 {
|
||||
keys = make([]string, maxLen)
|
||||
}
|
||||
|
||||
writeSecureSettings := func(secureSettings map[string]string) {
|
||||
// maps do not guarantee predictable sequence of keys.
|
||||
// Therefore, to make hash stable, we need to sort keys
|
||||
if len(secureSettings) == 0 {
|
||||
return
|
||||
}
|
||||
idx := 0
|
||||
for k := range secureSettings {
|
||||
keys[idx] = k
|
||||
idx++
|
||||
}
|
||||
sub := keys[:idx]
|
||||
sort.Strings(sub)
|
||||
for _, name := range sub {
|
||||
writeString(name)
|
||||
writeString(secureSettings[name])
|
||||
}
|
||||
}
|
||||
writeSecureSettings(in.SecureSettings)
|
||||
|
||||
writeSettings := func(settings map[string]any) {
|
||||
// maps do not guarantee predictable sequence of keys.
|
||||
// Therefore, to make hash stable, we need to sort keys
|
||||
if len(settings) == 0 {
|
||||
return
|
||||
}
|
||||
idx := 0
|
||||
for k := range settings {
|
||||
keys[idx] = k
|
||||
idx++
|
||||
}
|
||||
sub := keys[:idx]
|
||||
sort.Strings(sub)
|
||||
for _, name := range sub {
|
||||
writeString(name)
|
||||
|
||||
// TODO: Improve this.
|
||||
v := settings[name]
|
||||
bytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
writeString(fmt.Sprintf("%+v", v))
|
||||
} else {
|
||||
writeBytes(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
writeSettings(in.Settings)
|
||||
}
|
||||
|
||||
// fields that determine the rule state
|
||||
writeString(r.UID)
|
||||
writeString(r.Name)
|
||||
writeString(string(r.Provenance))
|
||||
|
||||
for _, integration := range r.Integrations {
|
||||
writeIntegration(integration)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%016x", sum.Sum64())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||
)
|
||||
|
||||
func TestReceiver_Clone(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
receiver Receiver
|
||||
}{
|
||||
{name: "empty receiver", receiver: Receiver{}},
|
||||
{name: "empty integration", receiver: Receiver{Integrations: []*Integration{{Config: IntegrationConfig{}}}}},
|
||||
{name: "random receiver", receiver: ReceiverGen()()},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
receiverClone := tc.receiver.Clone()
|
||||
assert.Equal(t, tc.receiver, receiverClone)
|
||||
|
||||
for _, integration := range tc.receiver.Integrations {
|
||||
integrationClone := integration.Clone()
|
||||
assert.Equal(t, *integration, integrationClone)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiver_EncryptDecrypt(t *testing.T) {
|
||||
encryptFn := Base64Enrypt
|
||||
decryptnFn := Base64Decrypt
|
||||
// Test that all known integration types encrypt and decrypt their secrets.
|
||||
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
|
||||
t.Run(integrationType, func(t *testing.T) {
|
||||
decrypedIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
|
||||
|
||||
encrypted := decrypedIntegration.Clone()
|
||||
secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType)
|
||||
assert.NoError(t, err)
|
||||
for _, key := range secrets {
|
||||
if val, ok := encrypted.Settings[key]; ok {
|
||||
if s, isString := val.(string); isString {
|
||||
encryptedVal, err := encryptFn(s)
|
||||
assert.NoError(t, err)
|
||||
encrypted.SecureSettings[key] = encryptedVal
|
||||
delete(encrypted.Settings, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testIntegration := decrypedIntegration.Clone()
|
||||
err = testIntegration.Encrypt(encryptFn)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, encrypted, testIntegration)
|
||||
|
||||
err = testIntegration.Decrypt(decryptnFn)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, decrypedIntegration, testIntegration)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Redact(t *testing.T) {
|
||||
redactFn := func(key string) string {
|
||||
return "TESTREDACTED"
|
||||
}
|
||||
// Test that all known integration types redact their secrets.
|
||||
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
|
||||
t.Run(integrationType, func(t *testing.T) {
|
||||
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
|
||||
|
||||
expected := validIntegration.Clone()
|
||||
secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType)
|
||||
assert.NoError(t, err)
|
||||
for _, key := range secrets {
|
||||
if val, ok := expected.Settings[key]; ok {
|
||||
if s, isString := val.(string); isString && s != "" {
|
||||
expected.Settings[key] = redactFn(s)
|
||||
delete(expected.SecureSettings, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validIntegration.Redact(redactFn)
|
||||
|
||||
assert.Equal(t, expected, validIntegration)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Validate(t *testing.T) {
|
||||
// Test that all known integration types are valid.
|
||||
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
|
||||
t.Run(integrationType, func(t *testing.T) {
|
||||
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
|
||||
assert.NoError(t, validIntegration.Encrypt(Base64Enrypt))
|
||||
assert.NoErrorf(t, validIntegration.Validate(Base64Decrypt), "integration should be valid")
|
||||
|
||||
invalidIntegration := IntegrationGen(IntegrationMuts.WithInvalidConfig(integrationType))()
|
||||
assert.NoError(t, invalidIntegration.Encrypt(Base64Enrypt))
|
||||
assert.Errorf(t, invalidIntegration.Validate(Base64Decrypt), "integration should be invalid")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_WithExistingSecureFields(t *testing.T) {
|
||||
// Test that WithExistingSecureFields will copy over the secure fields from the existing integration.
|
||||
testCases := []struct {
|
||||
name string
|
||||
integration Integration
|
||||
secureFields []string
|
||||
existing Integration
|
||||
expected Integration
|
||||
}{
|
||||
{
|
||||
name: "test receiver",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{
|
||||
"f1": "newVal1",
|
||||
"f2": "newVal2",
|
||||
"f3": "newVal3",
|
||||
"f5": "newVal5",
|
||||
},
|
||||
},
|
||||
secureFields: []string{"f2", "f4", "f5"},
|
||||
existing: Integration{
|
||||
SecureSettings: map[string]string{
|
||||
"f1": "oldVal1",
|
||||
"f2": "oldVal2",
|
||||
"f3": "oldVal3",
|
||||
"f4": "oldVal4",
|
||||
},
|
||||
},
|
||||
expected: Integration{
|
||||
SecureSettings: map[string]string{
|
||||
"f1": "newVal1",
|
||||
"f2": "oldVal2",
|
||||
"f3": "newVal3",
|
||||
"f4": "oldVal4",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Integration[exists], SecureFields[true], Existing[exists]: old value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{"f1": "newVal1"},
|
||||
},
|
||||
secureFields: []string{"f1"},
|
||||
existing: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
|
||||
expected: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
|
||||
},
|
||||
{
|
||||
name: "Integration[exists], SecureFields[true], Existing[missing]: no value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{"f1": "newVal1"},
|
||||
},
|
||||
secureFields: []string{"f1"},
|
||||
existing: Integration{SecureSettings: map[string]string{}},
|
||||
expected: Integration{SecureSettings: map[string]string{}},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Integration[exists], SecureFields[false], Existing[exists]: new value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{"f1": "newVal1"},
|
||||
},
|
||||
existing: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
|
||||
expected: Integration{SecureSettings: map[string]string{"f1": "newVal1"}},
|
||||
},
|
||||
{
|
||||
name: "Integration[exists], SecureFields[false], Existing[missing]: new value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{"f1": "newVal1"},
|
||||
},
|
||||
existing: Integration{SecureSettings: map[string]string{}},
|
||||
expected: Integration{SecureSettings: map[string]string{"f1": "newVal1"}},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Integration[missing], SecureFields[true], Existing[exists]: old value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{},
|
||||
},
|
||||
secureFields: []string{"f1"},
|
||||
existing: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
|
||||
expected: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
|
||||
},
|
||||
{
|
||||
name: "Integration[missing], SecureFields[true], Existing[missing]: no value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{},
|
||||
},
|
||||
secureFields: []string{"f1"},
|
||||
existing: Integration{SecureSettings: map[string]string{}},
|
||||
expected: Integration{SecureSettings: map[string]string{}},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Integration[missing], SecureFields[false], Existing[exists]: no value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{},
|
||||
},
|
||||
existing: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
|
||||
expected: Integration{SecureSettings: map[string]string{}},
|
||||
},
|
||||
{
|
||||
name: "Integration[missing], SecureFields[false], Existing[missing]: no value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{},
|
||||
},
|
||||
existing: Integration{SecureSettings: map[string]string{}},
|
||||
expected: Integration{SecureSettings: map[string]string{}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.integration.WithExistingSecureFields(&tc.existing, tc.secureFields)
|
||||
assert.Equal(t, tc.expected, tc.integration)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationConfig(t *testing.T) {
|
||||
// Test that all known integration types have a config and correctly mark their secrets as secure.
|
||||
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
|
||||
t.Run(integrationType, func(t *testing.T) {
|
||||
config, err := IntegrationConfigFromType(integrationType)
|
||||
assert.NoError(t, err)
|
||||
|
||||
secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType)
|
||||
assert.NoError(t, err)
|
||||
allSecrets := make(map[string]struct{}, len(secrets))
|
||||
for _, key := range secrets {
|
||||
allSecrets[key] = struct{}{}
|
||||
}
|
||||
|
||||
for field := range config.Fields {
|
||||
_, isSecret := allSecrets[field]
|
||||
assert.Equal(t, isSecret, config.IsSecureField(field))
|
||||
}
|
||||
assert.False(t, config.IsSecureField("__--**unknown_field**--__"))
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Unknown type returns error", func(t *testing.T) {
|
||||
_, err := IntegrationConfigFromType("__--**unknown_type**--__")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_SecureFields(t *testing.T) {
|
||||
// Test that all known integration types have a config and correctly mark their secrets as secure.
|
||||
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
|
||||
t.Run(integrationType, func(t *testing.T) {
|
||||
t.Run("contains SecureSettings", func(t *testing.T) {
|
||||
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
|
||||
expected := make(map[string]bool, len(validIntegration.SecureSettings))
|
||||
for field := range validIntegration.Config.Fields {
|
||||
if validIntegration.Config.IsSecureField(field) {
|
||||
expected[field] = true
|
||||
validIntegration.SecureSettings[field] = "test"
|
||||
delete(validIntegration.Settings, field)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, expected, validIntegration.SecureFields())
|
||||
})
|
||||
|
||||
t.Run("contains secret Settings not in SecureSettings", func(t *testing.T) {
|
||||
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
|
||||
expected := make(map[string]bool, len(validIntegration.SecureSettings))
|
||||
for field := range validIntegration.Config.Fields {
|
||||
if validIntegration.Config.IsSecureField(field) {
|
||||
expected[field] = true
|
||||
validIntegration.Settings[field] = "test"
|
||||
delete(validIntegration.SecureSettings, field)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, expected, validIntegration.SecureFields())
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// This is a broken type that will error if marshalled.
|
||||
type broken struct {
|
||||
f1 string
|
||||
}
|
||||
|
||||
func (b broken) MarshalJSON() ([]byte, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
|
||||
func TestReceiver_Fingerprint(t *testing.T) {
|
||||
// Test that the fingerprint is stable.
|
||||
im := IntegrationMuts
|
||||
baseReceiver := ReceiverGen(ReceiverMuts.WithName("test receiver"), ReceiverMuts.WithIntegrations(
|
||||
IntegrationGen(im.WithName("test receiver"), im.WithValidConfig("slack"))(),
|
||||
))()
|
||||
baseReceiver.Integrations[0].UID = "stable UID"
|
||||
baseReceiver.Integrations[0].DisableResolveMessage = true
|
||||
baseReceiver.Integrations[0].SecureSettings = map[string]string{"test2": "test2"}
|
||||
baseReceiver.Integrations[0].Settings["broken"] = broken{f1: "this"} // Add a broken type to ensure it is stable in the fingerprint.
|
||||
baseReceiver.Integrations[0].Config = IntegrationConfig{Type: baseReceiver.Integrations[0].Config.Type} // Remove all fields except Type.
|
||||
|
||||
completelyDifferentReceiver := ReceiverGen(ReceiverMuts.WithName("test receiver2"), ReceiverMuts.WithIntegrations(
|
||||
IntegrationGen(im.WithName("test receiver2"), im.WithValidConfig("discord"))(),
|
||||
))()
|
||||
completelyDifferentReceiver.Integrations[0].UID = "stable UID2"
|
||||
completelyDifferentReceiver.Integrations[0].DisableResolveMessage = false
|
||||
completelyDifferentReceiver.Integrations[0].SecureSettings = map[string]string{"test": "test"}
|
||||
completelyDifferentReceiver.Provenance = ProvenanceAPI
|
||||
completelyDifferentReceiver.Integrations[0].Config = IntegrationConfig{Type: completelyDifferentReceiver.Integrations[0].Config.Type} // Remove all fields except Type.
|
||||
|
||||
t.Run("stable across code changes", func(t *testing.T) {
|
||||
expectedFingerprint := "ae141b582965f4f5" // If this is a valid fingerprint generation change, update the expected value.
|
||||
assert.Equal(t, expectedFingerprint, baseReceiver.Fingerprint())
|
||||
})
|
||||
t.Run("stable across clones", func(t *testing.T) {
|
||||
fingerprint := baseReceiver.Fingerprint()
|
||||
receiverClone := baseReceiver.Clone()
|
||||
assert.Equal(t, fingerprint, receiverClone.Fingerprint())
|
||||
})
|
||||
t.Run("stable across Version field modification", func(t *testing.T) {
|
||||
fingerprint := baseReceiver.Fingerprint()
|
||||
receiverClone := baseReceiver.Clone()
|
||||
receiverClone.Version = "new version"
|
||||
assert.Equal(t, fingerprint, receiverClone.Fingerprint())
|
||||
})
|
||||
t.Run("unstable across field modification", func(t *testing.T) {
|
||||
fingerprint := baseReceiver.Fingerprint()
|
||||
excludedFields := map[string]struct{}{
|
||||
"Version": {},
|
||||
}
|
||||
|
||||
reflectVal := reflect.ValueOf(&completelyDifferentReceiver).Elem()
|
||||
|
||||
receiverType := reflect.TypeOf((*Receiver)(nil)).Elem()
|
||||
for i := 0; i < receiverType.NumField(); i++ {
|
||||
field := receiverType.Field(i).Name
|
||||
if _, ok := excludedFields[field]; ok {
|
||||
continue
|
||||
}
|
||||
cp := baseReceiver.Clone()
|
||||
|
||||
// Get the current field being modified.
|
||||
v := reflect.ValueOf(&cp).Elem()
|
||||
vf := v.Field(i)
|
||||
|
||||
otherField := reflectVal.Field(i)
|
||||
if reflect.DeepEqual(otherField.Interface(), vf.Interface()) {
|
||||
assert.Failf(t, "filds are identical", "Receiver field %s is the same as the original, test does not ensure instability across the field", field)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set the field to the value of the completelyDifferentReceiver.
|
||||
vf.Set(otherField)
|
||||
|
||||
f2 := cp.Fingerprint()
|
||||
assert.NotEqualf(t, fingerprint, f2, "Receiver field %s does not seem to be used in fingerprint", field)
|
||||
}
|
||||
|
||||
excludedFields = map[string]struct{}{}
|
||||
|
||||
reflectVal = reflect.ValueOf(completelyDifferentReceiver.Integrations[0]).Elem()
|
||||
integrationType := reflect.TypeOf((*Integration)(nil)).Elem()
|
||||
for i := 0; i < integrationType.NumField(); i++ {
|
||||
field := integrationType.Field(i).Name
|
||||
if _, ok := excludedFields[field]; ok {
|
||||
continue
|
||||
}
|
||||
cp := baseReceiver.Clone()
|
||||
integrationCp := cp.Integrations[0]
|
||||
|
||||
// Get the current field being modified.
|
||||
v := reflect.ValueOf(integrationCp).Elem()
|
||||
vf := v.Field(i)
|
||||
|
||||
otherField := reflectVal.Field(i)
|
||||
if reflect.DeepEqual(otherField.Interface(), vf.Interface()) {
|
||||
assert.Failf(t, "filds are identical", "Integration field %s is the same as the original, test does not ensure instability across the field", field)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set the field to the value of the completelyDifferentReceiver.
|
||||
vf.Set(otherField)
|
||||
|
||||
f2 := cp.Fingerprint()
|
||||
assert.NotEqualf(t, fingerprint, f2, "Integration field %s does not seem to be used in fingerprint", field)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
@@ -10,11 +11,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-openapi/strfmt"
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
amv2 "github.com/prometheus/alertmanager/api/v2/models"
|
||||
"github.com/prometheus/alertmanager/pkg/labels"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
alertingModels "github.com/grafana/alerting/models"
|
||||
|
||||
@@ -1092,6 +1095,220 @@ func (n SilenceMutators) WithEmptyId() Mutator[Silence] {
|
||||
}
|
||||
}
|
||||
|
||||
// Receivers
|
||||
|
||||
// CopyReceiverWith creates a deep copy of Receiver and then applies mutators to it.
|
||||
func CopyReceiverWith(r Receiver, mutators ...Mutator[Receiver]) Receiver {
|
||||
c := r.Clone()
|
||||
for _, mutator := range mutators {
|
||||
mutator(&c)
|
||||
}
|
||||
c.Version = c.Fingerprint()
|
||||
return c
|
||||
}
|
||||
|
||||
// ReceiverGen generates Receiver using a base and mutators.
|
||||
func ReceiverGen(mutators ...Mutator[Receiver]) func() Receiver {
|
||||
return func() Receiver {
|
||||
name := util.GenerateShortUID()
|
||||
integration := IntegrationGen(IntegrationMuts.WithName(name))()
|
||||
c := Receiver{
|
||||
UID: nameToUid(name),
|
||||
Name: name,
|
||||
Integrations: []*Integration{&integration},
|
||||
Provenance: ProvenanceNone,
|
||||
}
|
||||
for _, mutator := range mutators {
|
||||
mutator(&c)
|
||||
}
|
||||
c.Version = c.Fingerprint()
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
ReceiverMuts = ReceiverMutators{}
|
||||
)
|
||||
|
||||
type ReceiverMutators struct{}
|
||||
|
||||
func (n ReceiverMutators) WithName(name string) Mutator[Receiver] {
|
||||
return func(r *Receiver) {
|
||||
r.Name = name
|
||||
r.UID = nameToUid(name)
|
||||
}
|
||||
}
|
||||
|
||||
func (n ReceiverMutators) WithProvenance(provenance Provenance) Mutator[Receiver] {
|
||||
return func(r *Receiver) {
|
||||
r.Provenance = provenance
|
||||
}
|
||||
}
|
||||
|
||||
func (n ReceiverMutators) WithValidIntegration(integrationType string) Mutator[Receiver] {
|
||||
return func(r *Receiver) {
|
||||
integration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
|
||||
r.Integrations = []*Integration{&integration}
|
||||
}
|
||||
}
|
||||
|
||||
func (n ReceiverMutators) WithInvalidIntegration(integrationType string) Mutator[Receiver] {
|
||||
return func(r *Receiver) {
|
||||
integration := IntegrationGen(IntegrationMuts.WithInvalidConfig(integrationType))()
|
||||
r.Integrations = []*Integration{&integration}
|
||||
}
|
||||
}
|
||||
|
||||
func (n ReceiverMutators) WithIntegrations(integration ...Integration) Mutator[Receiver] {
|
||||
return func(r *Receiver) {
|
||||
integrations := make([]*Integration, len(integration))
|
||||
for i, v := range integration {
|
||||
clone := v.Clone()
|
||||
integrations[i] = &clone
|
||||
}
|
||||
r.Integrations = integrations
|
||||
}
|
||||
}
|
||||
|
||||
func (n ReceiverMutators) Encrypted(fn EncryptFn) Mutator[Receiver] {
|
||||
return func(r *Receiver) {
|
||||
_ = r.Encrypt(fn)
|
||||
}
|
||||
}
|
||||
func (n ReceiverMutators) Decrypted(fn DecryptFn) Mutator[Receiver] {
|
||||
return func(r *Receiver) {
|
||||
_ = r.Decrypt(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// Integrations
|
||||
|
||||
// CopyIntegrationWith creates a deep copy of Integration and then applies mutators to it.
|
||||
func CopyIntegrationWith(r Integration, mutators ...Mutator[Integration]) Integration {
|
||||
c := r.Clone()
|
||||
for _, mutator := range mutators {
|
||||
mutator(&c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// IntegrationGen generates Integration using a base and mutators.
|
||||
func IntegrationGen(mutators ...Mutator[Integration]) func() Integration {
|
||||
return func() Integration {
|
||||
name := util.GenerateShortUID()
|
||||
randomIntegrationType, _ := randomMapKey(alertingNotify.AllKnownConfigsForTesting)
|
||||
|
||||
c := Integration{
|
||||
UID: util.GenerateShortUID(),
|
||||
Name: name,
|
||||
DisableResolveMessage: rand.Intn(2) == 1,
|
||||
Settings: make(map[string]any),
|
||||
SecureSettings: make(map[string]string),
|
||||
}
|
||||
|
||||
IntegrationMuts.WithValidConfig(randomIntegrationType)(&c)
|
||||
|
||||
for _, mutator := range mutators {
|
||||
mutator(&c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
IntegrationMuts = IntegrationMutators{}
|
||||
Base64Enrypt = func(s string) (string, error) {
|
||||
return base64.StdEncoding.EncodeToString([]byte(s)), nil
|
||||
}
|
||||
Base64Decrypt = func(s string) (string, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
return string(b), err
|
||||
}
|
||||
)
|
||||
|
||||
type IntegrationMutators struct{}
|
||||
|
||||
func (n IntegrationMutators) WithUID(uid string) Mutator[Integration] {
|
||||
return func(s *Integration) {
|
||||
s.UID = uid
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) WithName(name string) Mutator[Integration] {
|
||||
return func(s *Integration) {
|
||||
s.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) WithValidConfig(integrationType string) Mutator[Integration] {
|
||||
return func(c *Integration) {
|
||||
config := alertingNotify.AllKnownConfigsForTesting[integrationType].GetRawNotifierConfig(c.Name)
|
||||
integrationConfig, _ := IntegrationConfigFromType(integrationType)
|
||||
c.Config = integrationConfig
|
||||
|
||||
var settings map[string]any
|
||||
_ = json.Unmarshal(config.Settings, &settings)
|
||||
|
||||
c.Settings = settings
|
||||
|
||||
// Decrypt secure settings over to normal settings.
|
||||
for k, v := range c.SecureSettings {
|
||||
decodeValue, _ := base64.StdEncoding.DecodeString(v)
|
||||
settings[k] = string(decodeValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) WithInvalidConfig(integrationType string) Mutator[Integration] {
|
||||
return func(c *Integration) {
|
||||
integrationConfig, _ := IntegrationConfigFromType(integrationType)
|
||||
c.Config = integrationConfig
|
||||
c.Settings = map[string]interface{}{}
|
||||
c.SecureSettings = map[string]string{}
|
||||
if integrationType == "webex" {
|
||||
// Webex passes validation without any settings but should fail with an unparsable URL.
|
||||
c.Settings["api_url"] = "(*^$*^%!@#$*()"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) WithSettings(settings map[string]any) Mutator[Integration] {
|
||||
return func(c *Integration) {
|
||||
c.Settings = maps.Clone(settings)
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) AddSetting(key string, val any) Mutator[Integration] {
|
||||
return func(c *Integration) {
|
||||
c.Settings[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) WithSecureSettings(secureSettings map[string]string) Mutator[Integration] {
|
||||
return func(r *Integration) {
|
||||
r.SecureSettings = maps.Clone(secureSettings)
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) AddSecureSetting(key, val string) Mutator[Integration] {
|
||||
return func(r *Integration) {
|
||||
r.SecureSettings[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
func randomMapKey[K comparable, V any](m map[K]V) (K, V) {
|
||||
randIdx := rand.Intn(len(m))
|
||||
i := 0
|
||||
|
||||
for key, val := range m {
|
||||
if i == randIdx {
|
||||
return key, val
|
||||
}
|
||||
i++
|
||||
}
|
||||
return *new(K), *new(V)
|
||||
}
|
||||
|
||||
func ConvertToRecordingRule(rule *AlertRule) {
|
||||
if rule.Record == nil {
|
||||
rule.Record = &Record{}
|
||||
@@ -1108,3 +1325,7 @@ func ConvertToRecordingRule(rule *AlertRule) {
|
||||
rule.For = 0
|
||||
rule.NotificationSettings = nil
|
||||
}
|
||||
|
||||
func nameToUid(name string) string { // Avoid legacy_storage.NameToUid import cycle.
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(name))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user