mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Support secrets in contact points nested fields (#92035)
Back-end: * update alerting module * update GetSecretKeysForContactPointType to extract secret fields from nested options * Update RemoveSecretsForContactPoint to support complex settings * update PostableGrafanaReceiverToEmbeddedContactPoint to support nested secrets * update Integration to support nested settings in models.Integration * make sigv4 fields optional Front-end: * add UI support for encrypted subform fields * allow emptying nested secure fields * Omit non touched secure fields in POST payload when saving a contact point * Use SecretInput from grafana-ui instead of the new EncryptedInput * use produce from immer * rename mapClone * rename sliceClone * Don't use produce from immer as we need to delete the fileds afterwards --------- Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com> Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com> Co-authored-by: Matt Jacobson <matthew.jacobson@grafana.com>
This commit is contained in:
parent
78ce9b8f39
commit
cb372d3fa8
.betterer.resultsgo.modgo.sum
pkg/services/ngalert
api/tooling/definitions
models
notifier
provisioning
public/app/features/alerting/unified
components/receivers/form
utils
@ -1883,10 +1883,9 @@ exports[`better eslint`] = {
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "2"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
||||
[0, 0, 0, "Do not use any type assertions.", "1"],
|
||||
[0, 0, 0, "Unexpected any. Specify a different type.", "2"]
|
||||
],
|
||||
"public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx:5381": [
|
||||
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
|
||||
|
2
go.mod
2
go.mod
@ -74,7 +74,7 @@ require (
|
||||
github.com/googleapis/gax-go/v2 v2.13.0 // @grafana/grafana-backend-group
|
||||
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
|
||||
github.com/gorilla/websocket v1.5.0 // @grafana/grafana-app-platform-squad
|
||||
github.com/grafana/alerting v0.0.0-20240822131459-9daa6239cc41 // @grafana/alerting-backend
|
||||
github.com/grafana/alerting v0.0.0-20240829185616-8454ac21d7e5 // @grafana/alerting-backend
|
||||
github.com/grafana/authlib v0.0.0-20240906122029-0100695765b9 // @grafana/identity-access-team
|
||||
github.com/grafana/authlib/claims v0.0.0-20240903121118-16441568af1e // @grafana/identity-access-team
|
||||
github.com/grafana/codejen v0.0.3 // @grafana/dataviz-squad
|
||||
|
4
go.sum
4
go.sum
@ -2257,8 +2257,8 @@ github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grafana/alerting v0.0.0-20240822131459-9daa6239cc41 h1:p+UsX43BoDH5YlG6xUd9xDS3M4sWouy8VJ+ODv5S6uE=
|
||||
github.com/grafana/alerting v0.0.0-20240822131459-9daa6239cc41/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4=
|
||||
github.com/grafana/alerting v0.0.0-20240829185616-8454ac21d7e5 h1:gQprHfu5/GT/mpPuRm3QVL+7+0j1QsvKJuPIzQAsezM=
|
||||
github.com/grafana/alerting v0.0.0-20240829185616-8454ac21d7e5/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4=
|
||||
github.com/grafana/authlib v0.0.0-20240906122029-0100695765b9 h1:e+kFqd2sECBhbxOV1NoVxsudLygNQuu9bO+7FjNTkXo=
|
||||
github.com/grafana/authlib v0.0.0-20240906122029-0100695765b9/go.mod h1:PFzXbCrn0GIpN4KwT6NP1l5Z1CPLfmKHnYx8rZzQcyY=
|
||||
github.com/grafana/authlib/claims v0.0.0-20240903121118-16441568af1e h1:ng5SopWamGS0MHaCj2e5huWYxAfMeCrj1l/dbJnfiow=
|
||||
|
@ -183,11 +183,11 @@ type SensugoIntegration struct {
|
||||
}
|
||||
|
||||
type SigV4Config struct {
|
||||
Region string `json:"region,omitempty" yaml:"region,omitempty" hcl:"region"`
|
||||
AccessKey string `json:"access_key,omitempty" yaml:"access_key,omitempty" hcl:"access_key"`
|
||||
SecretKey string `json:"secret_key,omitempty" yaml:"secret_key,omitempty" hcl:"secret_key"`
|
||||
Profile string `json:"profile,omitempty" yaml:"profile,omitempty" hcl:"profile"`
|
||||
RoleARN string `json:"role_arn,omitempty" yaml:"role_arn,omitempty" hcl:"role_arn"`
|
||||
Region *string `json:"region,omitempty" yaml:"region,omitempty" hcl:"region"`
|
||||
AccessKey *Secret `json:"access_key,omitempty" yaml:"access_key,omitempty" hcl:"access_key"`
|
||||
SecretKey *Secret `json:"secret_key,omitempty" yaml:"secret_key,omitempty" hcl:"secret_key"`
|
||||
Profile *string `json:"profile,omitempty" yaml:"profile,omitempty" hcl:"profile"`
|
||||
RoleARN *string `json:"role_arn,omitempty" yaml:"role_arn,omitempty" hcl:"role_arn"`
|
||||
}
|
||||
|
||||
type SnsIntegration struct {
|
||||
|
55
pkg/services/ngalert/models/fingerprint.go
Normal file
55
pkg/services/ngalert/models/fingerprint.go
Normal file
@ -0,0 +1,55 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"hash"
|
||||
"hash/fnv"
|
||||
"math"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// fingerprint is a wrapper for hash.Hash64 that adds utility methods to simplify hash calculation of structs
|
||||
type fingerprint struct {
|
||||
h hash.Hash64
|
||||
}
|
||||
|
||||
// creates a fingerprint that is backed by 64bit FNV-1a hash
|
||||
func newFingerprint() fingerprint {
|
||||
return fingerprint{h: fnv.New64a()}
|
||||
}
|
||||
|
||||
func (f fingerprint) String() string {
|
||||
return fmt.Sprintf("%016x", f.h.Sum64())
|
||||
}
|
||||
|
||||
func (f fingerprint) writeBytes(b []byte) {
|
||||
_, _ = f.h.Write(b)
|
||||
// add a byte sequence that cannot happen in UTF-8 strings.
|
||||
_, _ = f.h.Write([]byte{255})
|
||||
}
|
||||
|
||||
func (f fingerprint) writeString(s string) {
|
||||
if len(s) == 0 {
|
||||
f.writeBytes(nil)
|
||||
return
|
||||
}
|
||||
// #nosec G103
|
||||
// avoid allocation when converting string to byte slice
|
||||
f.writeBytes(unsafe.Slice(unsafe.StringData(s), len(s)))
|
||||
}
|
||||
|
||||
func (f fingerprint) writeFloat64(num float64) {
|
||||
bits := math.Float64bits(num)
|
||||
bytes := make([]byte, 8)
|
||||
binary.LittleEndian.PutUint64(bytes, bits)
|
||||
f.writeBytes(bytes)
|
||||
}
|
||||
|
||||
func (f fingerprint) writeBool(b bool) {
|
||||
if b {
|
||||
f.writeBytes([]byte{1})
|
||||
} else {
|
||||
f.writeBytes([]byte{0})
|
||||
}
|
||||
}
|
@ -2,15 +2,14 @@ package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"maps"
|
||||
"math"
|
||||
"slices"
|
||||
"sort"
|
||||
"unsafe"
|
||||
"strings"
|
||||
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
|
||||
@ -148,9 +147,39 @@ type IntegrationConfig struct {
|
||||
// IntegrationField represents a field in an integration configuration.
|
||||
type IntegrationField struct {
|
||||
Name string
|
||||
Fields map[string]IntegrationField
|
||||
Secure bool
|
||||
}
|
||||
|
||||
type IntegrationFieldPath []string
|
||||
|
||||
func NewIntegrationFieldPath(path string) IntegrationFieldPath {
|
||||
return strings.Split(path, ".")
|
||||
}
|
||||
|
||||
func (f IntegrationFieldPath) Head() string {
|
||||
if len(f) > 0 {
|
||||
return f[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (f IntegrationFieldPath) Tail() IntegrationFieldPath {
|
||||
return f[1:]
|
||||
}
|
||||
|
||||
func (f IntegrationFieldPath) IsLeaf() bool {
|
||||
return len(f) == 1
|
||||
}
|
||||
|
||||
func (f IntegrationFieldPath) String() string {
|
||||
return strings.Join(f, ".")
|
||||
}
|
||||
|
||||
func (f IntegrationFieldPath) Append(segment string) IntegrationFieldPath {
|
||||
return append(f, segment)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@ -160,23 +189,60 @@ func IntegrationConfigFromType(integrationType string) (IntegrationConfig, error
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
integrationConfig.Fields[option.PropertyName] = notifierOptionToIntegrationField(option)
|
||||
}
|
||||
return integrationConfig, nil
|
||||
}
|
||||
|
||||
func notifierOptionToIntegrationField(option channels_config.NotifierOption) IntegrationField {
|
||||
f := IntegrationField{
|
||||
Name: option.PropertyName,
|
||||
Secure: option.Secure,
|
||||
Fields: make(map[string]IntegrationField, len(option.SubformOptions)),
|
||||
}
|
||||
for _, subformOption := range option.SubformOptions {
|
||||
f.Fields[subformOption.PropertyName] = notifierOptionToIntegrationField(subformOption)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// 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
|
||||
func (config *IntegrationConfig) IsSecureField(path IntegrationFieldPath) bool {
|
||||
f, ok := config.GetField(path)
|
||||
return ok && f.Secure
|
||||
}
|
||||
|
||||
func (config *IntegrationConfig) GetField(path IntegrationFieldPath) (IntegrationField, bool) {
|
||||
for _, integrationField := range config.Fields {
|
||||
if strings.EqualFold(integrationField.Name, path.Head()) {
|
||||
if path.IsLeaf() {
|
||||
return integrationField, true
|
||||
}
|
||||
return integrationField.GetField(path.Tail())
|
||||
}
|
||||
}
|
||||
return false
|
||||
return IntegrationField{}, false
|
||||
}
|
||||
|
||||
func (config *IntegrationConfig) GetSecretFields() []IntegrationFieldPath {
|
||||
return traverseFields(config.Fields, nil, func(i IntegrationField) bool {
|
||||
return i.Secure
|
||||
})
|
||||
}
|
||||
|
||||
func traverseFields(flds map[string]IntegrationField, parentPath IntegrationFieldPath, predicate func(i IntegrationField) bool) []IntegrationFieldPath {
|
||||
var result []IntegrationFieldPath
|
||||
for key, field := range flds {
|
||||
if predicate(field) {
|
||||
result = append(result, parentPath.Append(key))
|
||||
}
|
||||
if len(field.Fields) > 0 {
|
||||
result = append(result, traverseFields(field.Fields, parentPath.Append(key), predicate)...)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (config *IntegrationConfig) Clone() IntegrationConfig {
|
||||
@ -193,11 +259,28 @@ func (config *IntegrationConfig) Clone() IntegrationConfig {
|
||||
return clone
|
||||
}
|
||||
|
||||
func (field *IntegrationField) GetField(path IntegrationFieldPath) (IntegrationField, bool) {
|
||||
for _, integrationField := range field.Fields {
|
||||
if strings.EqualFold(integrationField.Name, path.Head()) {
|
||||
if path.IsLeaf() {
|
||||
return integrationField, true
|
||||
}
|
||||
return integrationField.GetField(path.Tail())
|
||||
}
|
||||
}
|
||||
return IntegrationField{}, false
|
||||
}
|
||||
|
||||
func (field *IntegrationField) Clone() IntegrationField {
|
||||
return IntegrationField{
|
||||
f := IntegrationField{
|
||||
Name: field.Name,
|
||||
Secure: field.Secure,
|
||||
Fields: make(map[string]IntegrationField, len(field.Fields)),
|
||||
}
|
||||
for subName, sub := range field.Fields {
|
||||
f.Fields[subName] = sub.Clone()
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (integration *Integration) Clone() Integration {
|
||||
@ -206,42 +289,133 @@ func (integration *Integration) Clone() Integration {
|
||||
Name: integration.Name,
|
||||
Config: integration.Config.Clone(),
|
||||
DisableResolveMessage: integration.DisableResolveMessage,
|
||||
Settings: maps.Clone(integration.Settings),
|
||||
Settings: cloneIntegrationSettings(integration.Settings),
|
||||
SecureSettings: maps.Clone(integration.SecureSettings),
|
||||
}
|
||||
}
|
||||
|
||||
// cloneIntegrationSettings implements a deep copy of settings map.
|
||||
// It's not a generic purpose function because settings are limited to basic types, maps and slices.
|
||||
func cloneIntegrationSettings(m map[string]any) map[string]any {
|
||||
result := maps.Clone(m) // do a shallow copy of the map first
|
||||
for k, v := range result {
|
||||
if mp, ok := v.(map[string]any); ok {
|
||||
result[k] = cloneIntegrationSettings(mp)
|
||||
continue
|
||||
}
|
||||
if mp, ok := v.([]any); ok {
|
||||
result[k] = cloneIntegrationSettingsSlice(mp)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// cloneIntegrationSettingsSlice implements a deep copy of a []any in integration settings.
|
||||
// It's not a generic purpose function because settings are limited to basic types, maps and slices.
|
||||
func cloneIntegrationSettingsSlice(src []any) []any {
|
||||
dst := slices.Clone(src)
|
||||
for i, v := range dst {
|
||||
if mp, ok := v.(map[string]any); ok {
|
||||
dst[i] = cloneIntegrationSettings(mp)
|
||||
continue
|
||||
}
|
||||
if mp, ok := v.([]any); ok {
|
||||
dst[i] = cloneIntegrationSettingsSlice(mp)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// 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 {
|
||||
secretFieldPaths := integration.Config.GetSecretFields()
|
||||
if len(secretFieldPaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
var errs []error
|
||||
for key, val := range integration.Settings {
|
||||
if isSecureField := integration.Config.IsSecureField(key); !isSecureField {
|
||||
for _, path := range secretFieldPaths {
|
||||
unencryptedSecureValue, ok, err := extractField(integration.Settings, path)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to extract secret field by path '%s': %w", path, err))
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
delete(integration.Settings, key)
|
||||
unencryptedSecureValue, isString := val.(string)
|
||||
if !isString {
|
||||
if _, exists := integration.SecureSettings[path.String()]; exists {
|
||||
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))
|
||||
errs = append(errs, fmt.Errorf("failed to encrypt secure setting '%s': %w", path.String(), err))
|
||||
}
|
||||
|
||||
integration.SecureSettings[key] = encrypted
|
||||
integration.SecureSettings[path.String()] = encrypted
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func extractField(settings map[string]any, path IntegrationFieldPath) (string, bool, error) {
|
||||
val, ok := settings[path.Head()]
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
if path.IsLeaf() {
|
||||
secret, ok := val.(string)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("expected string but got %T", val)
|
||||
}
|
||||
delete(settings, path.Head())
|
||||
return secret, true, nil
|
||||
}
|
||||
sub, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
return "", false, fmt.Errorf("expected nested object but got %T", val)
|
||||
}
|
||||
return extractField(sub, path.Tail())
|
||||
}
|
||||
|
||||
func getFieldValue(settings map[string]any, path IntegrationFieldPath) (any, bool) {
|
||||
val, ok := settings[path.Head()]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if path.IsLeaf() {
|
||||
return val, true
|
||||
}
|
||||
sub, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return getFieldValue(sub, path.Tail())
|
||||
}
|
||||
|
||||
func setField(settings map[string]any, path IntegrationFieldPath, valueFn func(current any) any, skipIfNotExist bool) error {
|
||||
if path.IsLeaf() {
|
||||
current, ok := settings[path.Head()]
|
||||
if skipIfNotExist && !ok {
|
||||
return nil
|
||||
}
|
||||
settings[path.Head()] = valueFn(current)
|
||||
return nil
|
||||
}
|
||||
val, ok := settings[path.Head()]
|
||||
if !ok {
|
||||
if skipIfNotExist {
|
||||
return nil
|
||||
}
|
||||
val = map[string]any{}
|
||||
settings[path.Head()] = val
|
||||
}
|
||||
sub, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected nested object but got %T", val)
|
||||
}
|
||||
return setField(sub, path.Tail(), valueFn, skipIfNotExist)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@ -252,30 +426,35 @@ func (integration *Integration) Decrypt(decryptFn DecryptFn) error {
|
||||
errs = append(errs, fmt.Errorf("failed to decrypt secure setting '%s': %w", key, err))
|
||||
}
|
||||
delete(integration.SecureSettings, key)
|
||||
integration.Settings[key] = decrypted
|
||||
}
|
||||
|
||||
path := NewIntegrationFieldPath(key)
|
||||
err = setField(integration.Settings, path, func(current any) any {
|
||||
return decrypted
|
||||
}, false)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to set field '%s': %w", key, err))
|
||||
}
|
||||
}
|
||||
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)
|
||||
for _, path := range integration.Config.GetSecretFields() {
|
||||
_ = setField(integration.Settings, path, func(current any) any {
|
||||
if s, ok := current.(string); ok && s != "" {
|
||||
return redactFn(s)
|
||||
}
|
||||
return current
|
||||
}, true)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
for key, secureVal := range integration.SecureSettings { // TODO: Should we trust that the receiver is stored correctly or use known secure settings?
|
||||
_ = setField(integration.Settings, NewIntegrationFieldPath(key), func(any) any {
|
||||
return redactFn(secureVal)
|
||||
}, false)
|
||||
delete(integration.SecureSettings, key)
|
||||
}
|
||||
}
|
||||
|
||||
@ -304,11 +483,17 @@ func (integration *Integration) SecureFields() map[string]bool {
|
||||
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
|
||||
for _, path := range integration.Config.GetSecretFields() {
|
||||
if secureFields[path.String()] {
|
||||
continue
|
||||
}
|
||||
value, ok := getFieldValue(integration.Settings, path)
|
||||
if !ok || value == nil {
|
||||
continue
|
||||
}
|
||||
if v, _ := value.(string); v != "" {
|
||||
secureFields[path.String()] = true
|
||||
}
|
||||
}
|
||||
|
||||
@ -369,41 +554,16 @@ func (r *Receiver) GetUID() string {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
sum := newFingerprint()
|
||||
|
||||
writeIntegration := func(in *Integration) {
|
||||
writeString(in.UID)
|
||||
writeString(in.Name)
|
||||
sum.writeString(in.UID)
|
||||
sum.writeString(in.Name)
|
||||
|
||||
// Do not include fields in fingerprint as these are not part of the receiver definition.
|
||||
writeString(in.Config.Type)
|
||||
sum.writeString(in.Config.Type)
|
||||
|
||||
if in.DisableResolveMessage {
|
||||
writeInt(1)
|
||||
} else {
|
||||
writeInt(0)
|
||||
}
|
||||
sum.writeBool(in.DisableResolveMessage)
|
||||
|
||||
// allocate a slice that will be used for sorting keys, so we allocate it only once
|
||||
var keys []string
|
||||
@ -426,49 +586,51 @@ func (r *Receiver) Fingerprint() string {
|
||||
sub := keys[:idx]
|
||||
sort.Strings(sub)
|
||||
for _, name := range sub {
|
||||
writeString(name)
|
||||
writeString(secureSettings[name])
|
||||
sum.writeString(name)
|
||||
sum.writeString(secureSettings[name])
|
||||
}
|
||||
}
|
||||
writeSettings(sum, in.Settings)
|
||||
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))
|
||||
sum.writeString(r.UID)
|
||||
sum.writeString(r.Name)
|
||||
sum.writeString(string(r.Provenance))
|
||||
|
||||
for _, integration := range r.Integrations {
|
||||
writeIntegration(integration)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%016x", sum.Sum64())
|
||||
return sum.String()
|
||||
}
|
||||
|
||||
func writeSettings(f fingerprint, m map[string]any) {
|
||||
if len(m) == 0 {
|
||||
f.writeBytes(nil)
|
||||
return
|
||||
}
|
||||
keysIter := maps.Keys(m)
|
||||
keys := slices.Collect(keysIter)
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
f.writeString(key)
|
||||
switch v := m[key].(type) {
|
||||
case string:
|
||||
f.writeString(v)
|
||||
case bool:
|
||||
f.writeBool(v)
|
||||
case float64: // unmarshalling to map[string]any represents all numbers as float64
|
||||
f.writeFloat64(v)
|
||||
case map[string]any:
|
||||
writeSettings(f, v)
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
f.writeString(fmt.Sprintf("%+v", v))
|
||||
}
|
||||
f.writeBytes(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||
)
|
||||
@ -45,20 +46,19 @@ func TestReceiver_EncryptDecrypt(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
val, ok, err := extractField(encrypted.Settings, NewIntegrationFieldPath(key))
|
||||
assert.NoError(t, err)
|
||||
if ok {
|
||||
encryptedVal, err := encryptFn(val)
|
||||
assert.NoError(t, err)
|
||||
encrypted.SecureSettings[key] = encryptedVal
|
||||
}
|
||||
}
|
||||
|
||||
testIntegration := decrypedIntegration.Clone()
|
||||
err = testIntegration.Encrypt(encryptFn)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, encrypted, testIntegration)
|
||||
require.Equal(t, encrypted, testIntegration)
|
||||
|
||||
err = testIntegration.Decrypt(decryptnFn)
|
||||
assert.NoError(t, err)
|
||||
@ -80,12 +80,14 @@ func TestIntegration_Redact(t *testing.T) {
|
||||
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)
|
||||
err := setField(expected.Settings, NewIntegrationFieldPath(key), func(current any) any {
|
||||
if s, isString := current.(string); isString && s != "" {
|
||||
delete(expected.SecureSettings, key)
|
||||
return redactFn(s)
|
||||
}
|
||||
}
|
||||
return current
|
||||
}, true)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
validIntegration.Redact(redactFn)
|
||||
@ -244,9 +246,9 @@ func TestIntegrationConfig(t *testing.T) {
|
||||
|
||||
for field := range config.Fields {
|
||||
_, isSecret := allSecrets[field]
|
||||
assert.Equal(t, isSecret, config.IsSecureField(field))
|
||||
assert.Equalf(t, isSecret, config.IsSecureField(NewIntegrationFieldPath(field)), "field '%s' is expected to be secret", field)
|
||||
}
|
||||
assert.False(t, config.IsSecureField("__--**unknown_field**--__"))
|
||||
assert.False(t, config.IsSecureField(IntegrationFieldPath{"__--**unknown_field**--__"}))
|
||||
})
|
||||
}
|
||||
|
||||
@ -263,11 +265,13 @@ func TestIntegration_SecureFields(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)
|
||||
for _, path := range validIntegration.Config.GetSecretFields() {
|
||||
if validIntegration.Config.IsSecureField(path) {
|
||||
expected[path.String()] = true
|
||||
validIntegration.SecureSettings[path.String()] = "test"
|
||||
_, _, err := extractField(validIntegration.Settings, path)
|
||||
require.NoError(t, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
assert.Equal(t, expected, validIntegration.SecureFields())
|
||||
@ -276,11 +280,13 @@ func TestIntegration_SecureFields(t *testing.T) {
|
||||
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)
|
||||
for _, path := range validIntegration.Config.GetSecretFields() {
|
||||
if validIntegration.Config.IsSecureField(path) {
|
||||
expected[path.String()] = true
|
||||
assert.NoError(t, setField(validIntegration.Settings, path, func(current any) any {
|
||||
return "test"
|
||||
}, false))
|
||||
delete(validIntegration.SecureSettings, path.String())
|
||||
}
|
||||
}
|
||||
assert.Equal(t, expected, validIntegration.SecureFields())
|
||||
@ -306,8 +312,13 @@ func TestReceiver_Fingerprint(t *testing.T) {
|
||||
))()
|
||||
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].SecureSettings = map[string]string{"test2": "test2", "test3": "test223", "test1": "rest22"}
|
||||
baseReceiver.Integrations[0].Settings["broken"] = broken{f1: "this"} // Add a broken type to ensure it is stable in the fingerprint.
|
||||
baseReceiver.Integrations[0].Settings["sub-map"] = map[string]any{
|
||||
"setting": "value",
|
||||
"something": 123,
|
||||
"data": []string{"test"},
|
||||
} // 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(
|
||||
@ -320,7 +331,7 @@ func TestReceiver_Fingerprint(t *testing.T) {
|
||||
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.
|
||||
expectedFingerprint := "a3402fdaba03030c" // 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) {
|
||||
|
@ -1484,9 +1484,8 @@ func GetAvailableNotifiers() []*NotifierPlugin {
|
||||
{ // Since Grafana 11.1
|
||||
Type: "sns",
|
||||
Name: "AWS SNS",
|
||||
Description: "Sends notifications to Cisco Webex Teams",
|
||||
Description: "Sends notifications to AWS Simple Notification Service",
|
||||
Heading: "Webex settings",
|
||||
Info: "Notifications can be configured for any Cisco Webex Teams",
|
||||
Options: []NotifierOption{
|
||||
{
|
||||
Label: "The Amazon SNS API URL",
|
||||
@ -1602,18 +1601,30 @@ func GetSecretKeysForContactPointType(contactPointType string) ([]string, error)
|
||||
notifiers := GetAvailableNotifiers()
|
||||
for _, n := range notifiers {
|
||||
if strings.EqualFold(n.Type, contactPointType) {
|
||||
var secureFields []string
|
||||
for _, field := range n.Options {
|
||||
if field.Secure {
|
||||
secureFields = append(secureFields, field.PropertyName)
|
||||
}
|
||||
}
|
||||
return secureFields, nil
|
||||
return getSecretFields("", n.Options), nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no secrets configured for type '%s'", contactPointType)
|
||||
}
|
||||
|
||||
func getSecretFields(parentPath string, options []NotifierOption) []string {
|
||||
var secureFields []string
|
||||
for _, field := range options {
|
||||
name := field.PropertyName
|
||||
if parentPath != "" {
|
||||
name = parentPath + "." + name
|
||||
}
|
||||
if field.Secure {
|
||||
secureFields = append(secureFields, name)
|
||||
continue
|
||||
}
|
||||
if len(field.SubformOptions) > 0 {
|
||||
secureFields = append(secureFields, getSecretFields(name, field.SubformOptions)...)
|
||||
}
|
||||
}
|
||||
return secureFields
|
||||
}
|
||||
|
||||
// ConfigForIntegrationType returns the config for the given integration type. Returns error is integration type is not known.
|
||||
func ConfigForIntegrationType(contactPointType string) (*NotifierPlugin, error) {
|
||||
notifiers := GetAvailableNotifiers()
|
||||
|
@ -0,0 +1,89 @@
|
||||
package channels_config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetSecretKeysForContactPointType(t *testing.T) {
|
||||
testCases := []struct {
|
||||
receiverType string
|
||||
expectedSecretFields []string
|
||||
}{
|
||||
{receiverType: "dingding", expectedSecretFields: []string{}},
|
||||
{receiverType: "kafka", expectedSecretFields: []string{"password"}},
|
||||
{receiverType: "email", expectedSecretFields: []string{}},
|
||||
{receiverType: "pagerduty", expectedSecretFields: []string{"integrationKey"}},
|
||||
{receiverType: "victorops", expectedSecretFields: []string{}},
|
||||
{receiverType: "oncall", expectedSecretFields: []string{"password", "authorization_credentials"}},
|
||||
{receiverType: "pushover", expectedSecretFields: []string{"apiToken", "userKey"}},
|
||||
{receiverType: "slack", expectedSecretFields: []string{"token", "url"}},
|
||||
{receiverType: "sensugo", expectedSecretFields: []string{"apikey"}},
|
||||
{receiverType: "teams", expectedSecretFields: []string{}},
|
||||
{receiverType: "telegram", expectedSecretFields: []string{"bottoken"}},
|
||||
{receiverType: "webhook", expectedSecretFields: []string{"password", "authorization_credentials"}},
|
||||
{receiverType: "wecom", expectedSecretFields: []string{"url", "secret"}},
|
||||
{receiverType: "prometheus-alertmanager", expectedSecretFields: []string{"basicAuthPassword"}},
|
||||
{receiverType: "discord", expectedSecretFields: []string{"url"}},
|
||||
{receiverType: "googlechat", expectedSecretFields: []string{}},
|
||||
{receiverType: "line", expectedSecretFields: []string{"token"}},
|
||||
{receiverType: "threema", expectedSecretFields: []string{"api_secret"}},
|
||||
{receiverType: "opsgenie", expectedSecretFields: []string{"apiKey"}},
|
||||
{receiverType: "webex", expectedSecretFields: []string{"bot_token"}},
|
||||
{receiverType: "sns", expectedSecretFields: []string{"sigv4.access_key", "sigv4.secret_key"}},
|
||||
}
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.receiverType, func(t *testing.T) {
|
||||
got, err := GetSecretKeysForContactPointType(testCase.receiverType)
|
||||
require.NoError(t, err)
|
||||
t.Logf("got secret fields: %#v", got)
|
||||
require.ElementsMatch(t, testCase.expectedSecretFields, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getSecretFields(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
parentPath string
|
||||
options []NotifierOption
|
||||
expectedFields []string
|
||||
}{
|
||||
{
|
||||
name: "No secure fields",
|
||||
parentPath: "",
|
||||
options: []NotifierOption{
|
||||
{PropertyName: "field1", Secure: false, SubformOptions: nil},
|
||||
{PropertyName: "field2", Secure: false, SubformOptions: nil},
|
||||
},
|
||||
expectedFields: []string{},
|
||||
},
|
||||
{
|
||||
name: "Single secure field",
|
||||
parentPath: "",
|
||||
options: []NotifierOption{
|
||||
{PropertyName: "field1", Secure: true, SubformOptions: nil},
|
||||
{PropertyName: "field2", Secure: false, SubformOptions: nil},
|
||||
},
|
||||
expectedFields: []string{"field1"},
|
||||
},
|
||||
{
|
||||
name: "Secure field in subform",
|
||||
parentPath: "parent",
|
||||
options: []NotifierOption{
|
||||
{PropertyName: "field1", Secure: true, SubformOptions: nil},
|
||||
{PropertyName: "field2", Secure: false, SubformOptions: []NotifierOption{
|
||||
{PropertyName: "subfield1", Secure: true, SubformOptions: nil},
|
||||
}},
|
||||
},
|
||||
expectedFields: []string{"parent.field1", "parent.field2.subfield1"},
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := getSecretFields(tc.parentPath, tc.options)
|
||||
require.ElementsMatch(t, got, tc.expectedFields)
|
||||
})
|
||||
}
|
||||
}
|
@ -246,7 +246,7 @@ func TestReceiverService_Delete(t *testing.T) {
|
||||
name: "delete receiver used by route fails",
|
||||
user: writer,
|
||||
deleteUID: legacy_storage.NameToUid("grafana-default-email"),
|
||||
version: "1fd7897966a2adc5", // Correct version for grafana-default-email.
|
||||
version: "cd95627c75892a39", // Correct version for grafana-default-email.
|
||||
expectedErr: makeReceiverInUseErr(true, nil),
|
||||
},
|
||||
{
|
||||
@ -764,7 +764,7 @@ func TestReceiverService_UpdateReceiverName(t *testing.T) {
|
||||
newReceiverName := "new-name"
|
||||
slackIntegration := models.IntegrationGen(models.IntegrationMuts.WithName(receiverName), models.IntegrationMuts.WithValidConfig("slack"))()
|
||||
baseReceiver := models.ReceiverGen(models.ReceiverMuts.WithName(receiverName), models.ReceiverMuts.WithIntegrations(slackIntegration))()
|
||||
baseReceiver.Version = "1fd7897966a2adc5" // Correct version for grafana-default-email.
|
||||
baseReceiver.Version = "cd95627c75892a39" // Correct version for grafana-default-email.
|
||||
baseReceiver.Name = newReceiverName // Done here instead of in a mutator so we keep the same uid.
|
||||
|
||||
store := sut.ruleNotificationsStore.(*fakeConfigStore)
|
||||
|
@ -1,6 +1,8 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
@ -41,7 +43,7 @@ func PostableGrafanaReceiverToEmbeddedContactPoint(contactPoint *definitions.Pos
|
||||
if decryptedValue == "" {
|
||||
continue
|
||||
}
|
||||
embeddedContactPoint.Settings.Set(k, decryptedValue)
|
||||
embeddedContactPoint.Settings.SetPath(strings.Split(k, "."), decryptedValue)
|
||||
}
|
||||
return embeddedContactPoint, nil
|
||||
}
|
||||
|
125
pkg/services/ngalert/provisioning/compat_test.go
Normal file
125
pkg/services/ngalert/provisioning/compat_test.go
Normal file
@ -0,0 +1,125 @@
|
||||
package provisioning
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
func TestPostableGrafanaReceiverToEmbeddedContactPoint(t *testing.T) {
|
||||
expectedProvenance := models.KnownProvenances[rand.Intn(len(models.KnownProvenances))]
|
||||
tests := []struct {
|
||||
name string
|
||||
input definitions.PostableGrafanaReceiver
|
||||
expected definitions.EmbeddedContactPoint
|
||||
}{
|
||||
{
|
||||
name: "should create expected object",
|
||||
input: definitions.PostableGrafanaReceiver{
|
||||
UID: "test-uid",
|
||||
Name: "test-name",
|
||||
Type: "test-type",
|
||||
DisableResolveMessage: true,
|
||||
Settings: definitions.RawMessage(`{ "name": "test" }`),
|
||||
},
|
||||
expected: definitions.EmbeddedContactPoint{
|
||||
UID: "test-uid",
|
||||
Name: "test-name",
|
||||
Type: "test-type",
|
||||
Settings: simplejson.NewFromAny(map[string]any{
|
||||
"name": "test",
|
||||
}),
|
||||
DisableResolveMessage: true,
|
||||
Provenance: string(expectedProvenance),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should merge decrypted secrets into settings",
|
||||
input: definitions.PostableGrafanaReceiver{
|
||||
Settings: definitions.RawMessage(`{ "name": "test" }`),
|
||||
SecureSettings: map[string]string{
|
||||
"secret": "data",
|
||||
},
|
||||
},
|
||||
expected: definitions.EmbeddedContactPoint{
|
||||
Settings: simplejson.NewFromAny(map[string]any{
|
||||
"name": "test",
|
||||
"secret": "data",
|
||||
}),
|
||||
Provenance: string(expectedProvenance),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should override existing settings with decrypted secrets into settings",
|
||||
input: definitions.PostableGrafanaReceiver{
|
||||
Settings: definitions.RawMessage(`{ "name": "test" }`),
|
||||
SecureSettings: map[string]string{
|
||||
"name": "secret-data",
|
||||
},
|
||||
},
|
||||
expected: definitions.EmbeddedContactPoint{
|
||||
Settings: simplejson.NewFromAny(map[string]any{
|
||||
"name": "secret-data",
|
||||
}),
|
||||
Provenance: string(expectedProvenance),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should support nested secrets",
|
||||
input: definitions.PostableGrafanaReceiver{
|
||||
Settings: definitions.RawMessage(`{ "name": "test" }`),
|
||||
SecureSettings: map[string]string{
|
||||
"secret.sub-secret": "data",
|
||||
},
|
||||
},
|
||||
expected: definitions.EmbeddedContactPoint{
|
||||
Settings: simplejson.NewFromAny(map[string]any{
|
||||
"name": "test",
|
||||
"secret": map[string]any{
|
||||
"sub-secret": "data",
|
||||
},
|
||||
}),
|
||||
Provenance: string(expectedProvenance),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "should amend to nested structs",
|
||||
input: definitions.PostableGrafanaReceiver{
|
||||
Settings: definitions.RawMessage(`{ "name": "test", "secret": { "data": "test"} }`),
|
||||
SecureSettings: map[string]string{
|
||||
"secret.sub-secret": "secret-data",
|
||||
},
|
||||
},
|
||||
expected: definitions.EmbeddedContactPoint{
|
||||
Settings: simplejson.NewFromAny(map[string]any{
|
||||
"name": "test",
|
||||
"secret": map[string]any{
|
||||
"data": "test",
|
||||
"sub-secret": "secret-data",
|
||||
},
|
||||
}),
|
||||
Provenance: string(expectedProvenance),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var decrypted []string
|
||||
decrypt := func(s string) string {
|
||||
decrypted = append(decrypted, s)
|
||||
return s
|
||||
}
|
||||
embeddedContactPoint, err := PostableGrafanaReceiverToEmbeddedContactPoint(&tt.input, expectedProvenance, decrypt)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, embeddedContactPoint)
|
||||
assert.ElementsMatch(t, maps.Values(tt.input.SecureSettings), decrypted)
|
||||
})
|
||||
}
|
||||
}
|
@ -490,37 +490,62 @@ func RemoveSecretsForContactPoint(e *apimodels.EmbeddedContactPoint) (map[string
|
||||
return nil, err
|
||||
}
|
||||
for _, secretKey := range secretKeys {
|
||||
foundSecretKey, secretValue, err := getCaseInsensitive(e.Settings, secretKey)
|
||||
secretValue, err := extractCaseInsensitive(e.Settings, secretKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Settings.Del(foundSecretKey)
|
||||
if secretValue == "" {
|
||||
continue
|
||||
}
|
||||
s[secretKey] = secretValue
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// getCaseInsensitive returns the value of the specified key, preferring an exact match but accepting a case-insensitive match.
|
||||
// extractCaseInsensitive returns the value of the specified key, preferring an exact match but accepting a case-insensitive match.
|
||||
// If no key matches, the second return value is an empty string.
|
||||
func getCaseInsensitive(jsonObj *simplejson.Json, key string) (string, string, error) {
|
||||
// Check for an exact key match first.
|
||||
if value, ok := jsonObj.CheckGet(key); ok {
|
||||
return key, value.MustString(), nil
|
||||
func extractCaseInsensitive(jsonObj *simplejson.Json, key string) (string, error) {
|
||||
if key == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// If no exact match is found, look for a case-insensitive match.
|
||||
settingsMap, err := jsonObj.Map()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
for k, v := range settingsMap {
|
||||
if strings.EqualFold(k, key) {
|
||||
return k, v.(string), nil
|
||||
path := strings.Split(key, ".")
|
||||
getNodeCaseInsensitive := func(n *simplejson.Json, field string) (string, *simplejson.Json, error) {
|
||||
// Check for an exact key match first.
|
||||
if value, ok := n.CheckGet(field); ok {
|
||||
return field, value, nil
|
||||
}
|
||||
|
||||
// If no exact match is found, look for a case-insensitive match.
|
||||
settingsMap, err := n.Map()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
for k := range settingsMap {
|
||||
if strings.EqualFold(k, field) {
|
||||
return k, n.GetPath(k), nil
|
||||
}
|
||||
}
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
return key, "", nil
|
||||
node := jsonObj
|
||||
for idx, segment := range path {
|
||||
_, value, err := getNodeCaseInsensitive(node, segment)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if value == nil {
|
||||
return "", nil
|
||||
}
|
||||
if idx == len(path)-1 {
|
||||
resultValue := value.MustString()
|
||||
node.Del(segment)
|
||||
return resultValue, nil
|
||||
}
|
||||
node = value
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// convertRecSvcErr converts errors from notifier.ReceiverService to errors expected from ContactPointService.
|
||||
|
@ -4,12 +4,15 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/alerting/notify"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
@ -22,6 +25,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
|
||||
"github.com/grafana/grafana/pkg/services/secrets"
|
||||
@ -376,6 +380,60 @@ func TestContactPointServiceDecryptRedact(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveSecretsForContactPoint(t *testing.T) {
|
||||
overrides := map[string]func(settings map[string]any){
|
||||
"webhook": func(settings map[string]any) { // add additional field to the settings because valid config does not allow it to be specified along with password
|
||||
settings["authorization_credentials"] = "test-authz-creds"
|
||||
},
|
||||
}
|
||||
|
||||
configs := notify.AllKnownConfigsForTesting
|
||||
keys := maps.Keys(configs)
|
||||
slices.Sort(keys)
|
||||
for _, integrationType := range keys {
|
||||
cfg := configs[integrationType]
|
||||
var settings map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(cfg.Config), &settings))
|
||||
if f, ok := overrides[integrationType]; ok {
|
||||
f(settings)
|
||||
}
|
||||
settingsRaw, err := json.Marshal(settings)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedFields, err := channels_config.GetSecretKeysForContactPointType(integrationType)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run(integrationType, func(t *testing.T) {
|
||||
cp := definitions.EmbeddedContactPoint{
|
||||
Name: "integration-" + integrationType,
|
||||
Type: integrationType,
|
||||
Settings: simplejson.MustJson(settingsRaw),
|
||||
}
|
||||
secureFields, err := RemoveSecretsForContactPoint(&cp)
|
||||
require.NoError(t, err)
|
||||
|
||||
FIELDS_ASSERT:
|
||||
for _, field := range expectedFields {
|
||||
assert.Contains(t, secureFields, field)
|
||||
path := strings.Split(field, ".")
|
||||
var expectedValue any = settings
|
||||
for _, segment := range path {
|
||||
v, ok := expectedValue.(map[string]any)
|
||||
if !ok {
|
||||
assert.Fail(t, fmt.Sprintf("cannot get expected value for field '%s'", field))
|
||||
continue FIELDS_ASSERT
|
||||
}
|
||||
expectedValue = v[segment]
|
||||
}
|
||||
assert.EqualValues(t, secureFields[field], expectedValue)
|
||||
v, err := cp.Settings.GetPath(path...).Value()
|
||||
assert.NoError(t, err)
|
||||
assert.Nilf(t, v, "field %s is expected to be removed from the settings", field)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createContactPointServiceSut(t *testing.T, secretService secrets.Service) *ContactPointService {
|
||||
// Encrypt secure settings.
|
||||
cfg := createEncryptedConfig(t, secretService)
|
||||
|
@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { useFormContext, FieldError, FieldErrors, DeepMap } from 'react-hook-form';
|
||||
import { DeepMap, FieldError, FieldErrors, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Button, Field, Input } from '@grafana/ui';
|
||||
import { Field, SecretInput } from '@grafana/ui';
|
||||
import { NotificationChannelOption, NotificationChannelSecureFields } from 'app/types';
|
||||
|
||||
import { ChannelValues, ReceiverFormValues } from '../../../types/receiver-form';
|
||||
@ -33,6 +33,7 @@ export function ChannelOptions<R extends ChannelValues>({
|
||||
}: Props<R>): JSX.Element {
|
||||
const { watch } = useFormContext<ReceiverFormValues<R>>();
|
||||
const currentFormValues = watch(); // react hook form types ARE LYING!
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedChannelOptions.map((option: NotificationChannelOption, index: number) => {
|
||||
@ -50,18 +51,8 @@ export function ChannelOptions<R extends ChannelValues>({
|
||||
|
||||
if (secureFields && secureFields[option.propertyName]) {
|
||||
return (
|
||||
<Field key={key} label={option.label} description={option.description || undefined}>
|
||||
<Input
|
||||
readOnly={true}
|
||||
value="Configured"
|
||||
suffix={
|
||||
readOnly ? null : (
|
||||
<Button onClick={() => onResetSecureField(option.propertyName)} fill="text" type="button" size="sm">
|
||||
Clear
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Field key={key} label={option.label} description={option.description}>
|
||||
<SecretInput onReset={() => onResetSecureField(option.propertyName)} isConfigured />
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@ -74,6 +65,8 @@ export function ChannelOptions<R extends ChannelValues>({
|
||||
|
||||
return (
|
||||
<OptionField
|
||||
onResetSecureField={onResetSecureField}
|
||||
secureFields={secureFields}
|
||||
defaultValue={defaultValue}
|
||||
readOnly={readOnly}
|
||||
key={key}
|
||||
|
@ -94,7 +94,7 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
|
||||
const onResetSecureField = (key: string) => {
|
||||
if (_secureFields[key]) {
|
||||
const updatedSecureFields = { ...secureFields };
|
||||
const updatedSecureFields = { ..._secureFields };
|
||||
delete updatedSecureFields[key];
|
||||
setSecureFields(updatedSecureFields);
|
||||
setValue(`${pathPrefix}.secureFields`, updatedSecureFields);
|
||||
|
@ -93,6 +93,17 @@ export function ReceiverForm<R extends ChannelValues>({
|
||||
const { fields, append, remove } = useControlledFieldArray<R>({ name: 'items', formAPI, softDelete: true });
|
||||
|
||||
const submitCallback = async (values: ReceiverFormValues<R>) => {
|
||||
values.items.forEach((item) => {
|
||||
if (item.secureFields) {
|
||||
// omit secure fields with boolean value as BE expects not touched fields to be omitted: https://github.com/grafana/grafana/pull/71307
|
||||
Object.keys(item.secureFields).forEach((key) => {
|
||||
if (item.secureFields[key] === true || item.secureFields[key] === false) {
|
||||
delete item.secureFields[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
...values,
|
||||
|
@ -4,8 +4,8 @@ import { FC, useEffect } from 'react';
|
||||
import { Controller, DeepMap, FieldError, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Checkbox, Field, Input, RadioButtonList, Select, TextArea, useStyles2 } from '@grafana/ui';
|
||||
import { NotificationChannelOption } from 'app/types';
|
||||
import { Checkbox, Field, Input, RadioButtonList, SecretInput, Select, TextArea, useStyles2 } from '@grafana/ui';
|
||||
import { NotificationChannelOption, NotificationChannelSecureFields } from 'app/types';
|
||||
|
||||
import { KeyValueMapInput } from './KeyValueMapInput';
|
||||
import { StringArrayInput } from './StringArrayInput';
|
||||
@ -16,16 +16,21 @@ import { WrapWithTemplateSelection } from './TemplateSelector';
|
||||
interface Props {
|
||||
defaultValue: any;
|
||||
option: NotificationChannelOption;
|
||||
// this is defined if the option is rendered inside a subform
|
||||
parentOption?: NotificationChannelOption;
|
||||
invalid?: boolean;
|
||||
pathPrefix: string;
|
||||
pathSuffix?: string;
|
||||
error?: FieldError | DeepMap<any, FieldError>;
|
||||
readOnly?: boolean;
|
||||
customValidator?: (value: string) => boolean | string | Promise<boolean | string>;
|
||||
onResetSecureField?: (propertyName: string) => void;
|
||||
secureFields?: NotificationChannelSecureFields;
|
||||
}
|
||||
|
||||
export const OptionField: FC<Props> = ({
|
||||
option,
|
||||
parentOption,
|
||||
invalid,
|
||||
pathPrefix,
|
||||
pathSuffix = '',
|
||||
@ -33,13 +38,16 @@ export const OptionField: FC<Props> = ({
|
||||
defaultValue,
|
||||
readOnly = false,
|
||||
customValidator,
|
||||
onResetSecureField,
|
||||
secureFields = {},
|
||||
}) => {
|
||||
const optionPath = `${pathPrefix}${pathSuffix}`;
|
||||
const isSecure = pathSuffix === 'secureSettings.';
|
||||
|
||||
if (option.element === 'subform') {
|
||||
return (
|
||||
<SubformField
|
||||
secureFields={secureFields}
|
||||
onResetSecureField={onResetSecureField}
|
||||
readOnly={readOnly}
|
||||
defaultValue={defaultValue}
|
||||
option={option}
|
||||
@ -75,14 +83,16 @@ export const OptionField: FC<Props> = ({
|
||||
pathPrefix={optionPath}
|
||||
readOnly={readOnly}
|
||||
pathIndex={pathPrefix}
|
||||
parentOption={parentOption}
|
||||
customValidator={customValidator}
|
||||
isSecure={isSecure}
|
||||
onResetSecureField={onResetSecureField}
|
||||
secureFields={secureFields}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
const OptionInput: FC<Props & { id: string; pathIndex?: string; isSecure?: boolean }> = ({
|
||||
const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
|
||||
option,
|
||||
invalid,
|
||||
id,
|
||||
@ -90,18 +100,17 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string; isSecure?: boole
|
||||
pathIndex = '',
|
||||
readOnly = false,
|
||||
customValidator,
|
||||
isSecure,
|
||||
onResetSecureField,
|
||||
secureFields = {},
|
||||
parentOption,
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { control, register, unregister, getValues, setValue } = useFormContext();
|
||||
const name = `${pathPrefix}${option.propertyName}`;
|
||||
|
||||
useEffect(() => {
|
||||
// Remove the value of secure fields so it doesn't show the incorrect value when clearing the field
|
||||
if (isSecure) {
|
||||
setValue(name, null);
|
||||
}
|
||||
}, [isSecure, name, setValue]);
|
||||
const name = `${pathPrefix}${option.propertyName}`;
|
||||
const nestedKey = parentOption ? `${parentOption.propertyName}.${option.propertyName}` : option.propertyName;
|
||||
|
||||
const isEncryptedInput = secureFields?.[nestedKey];
|
||||
|
||||
// workaround for https://github.com/react-hook-form/react-hook-form/issues/4993#issuecomment-829012506
|
||||
useEffect(
|
||||
@ -138,22 +147,26 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string; isSecure?: boole
|
||||
name={name}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
>
|
||||
<Input
|
||||
id={id}
|
||||
readOnly={readOnly || useTemplates || determineReadOnly(option, getValues, pathIndex)}
|
||||
invalid={invalid}
|
||||
type={option.inputType}
|
||||
{...register(name, {
|
||||
required: determineRequired(option, getValues, pathIndex),
|
||||
validate: {
|
||||
validationRule: (v) =>
|
||||
option.validationRule ? validateOption(v, option.validationRule, option.required) : true,
|
||||
customValidator: (v) => (customValidator ? customValidator(v) : true),
|
||||
},
|
||||
setValueAs: option.setValueAs,
|
||||
})}
|
||||
placeholder={option.placeholder}
|
||||
/>
|
||||
{isEncryptedInput ? (
|
||||
<SecretInput onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
|
||||
) : (
|
||||
<Input
|
||||
id={id}
|
||||
readOnly={readOnly || useTemplates || determineReadOnly(option, getValues, pathIndex)}
|
||||
invalid={invalid}
|
||||
type={option.inputType}
|
||||
{...register(name, {
|
||||
required: determineRequired(option, getValues, pathIndex),
|
||||
validate: {
|
||||
validationRule: (v) =>
|
||||
option.validationRule ? validateOption(v, option.validationRule, option.required) : true,
|
||||
customValidator: (v) => (customValidator ? customValidator(v) : true),
|
||||
},
|
||||
setValueAs: option.setValueAs,
|
||||
})}
|
||||
placeholder={option.placeholder}
|
||||
/>
|
||||
)}
|
||||
</WrapWithTemplateSelection>
|
||||
);
|
||||
|
||||
|
@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { FieldError, DeepMap, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Button, useStyles2 } from '@grafana/ui';
|
||||
import { NotificationChannelOption } from 'app/types';
|
||||
import { NotificationChannelOption, NotificationChannelSecureFields } from 'app/types';
|
||||
|
||||
import { ActionIcon } from '../../../rules/ActionIcon';
|
||||
|
||||
@ -15,9 +15,19 @@ interface Props {
|
||||
pathPrefix: string;
|
||||
errors?: DeepMap<any, FieldError>;
|
||||
readOnly?: boolean;
|
||||
secureFields?: NotificationChannelSecureFields;
|
||||
onResetSecureField?: (propertyName: string) => void;
|
||||
}
|
||||
|
||||
export const SubformField = ({ option, pathPrefix, errors, defaultValue, readOnly = false }: Props) => {
|
||||
export const SubformField = ({
|
||||
option,
|
||||
pathPrefix,
|
||||
errors,
|
||||
defaultValue,
|
||||
readOnly = false,
|
||||
secureFields = {},
|
||||
onResetSecureField,
|
||||
}: Props) => {
|
||||
const styles = useStyles2(getReceiverFormFieldStyles);
|
||||
const name = `${pathPrefix}${option.propertyName}`;
|
||||
const { watch } = useFormContext();
|
||||
@ -45,7 +55,10 @@ export const SubformField = ({ option, pathPrefix, errors, defaultValue, readOnl
|
||||
return (
|
||||
<OptionField
|
||||
readOnly={readOnly}
|
||||
secureFields={secureFields}
|
||||
onResetSecureField={onResetSecureField}
|
||||
defaultValue={defaultValue?.[subOption.propertyName]}
|
||||
parentOption={option}
|
||||
key={subOption.propertyName}
|
||||
option={subOption}
|
||||
pathPrefix={`${name}.`}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { isArray, omit, pick, isNil, omitBy } from 'lodash';
|
||||
import { get, has, isArray, isNil, omit, omitBy, reduce } from 'lodash';
|
||||
|
||||
import {
|
||||
AlertManagerCortexConfig,
|
||||
@ -8,7 +8,7 @@ import {
|
||||
Receiver,
|
||||
Route,
|
||||
} from 'app/plugins/datasource/alertmanager/types';
|
||||
import { CloudNotifierType, NotifierDTO, NotifierType } from 'app/types';
|
||||
import { CloudNotifierType, NotificationChannelOption, NotifierDTO, NotifierType } from 'app/types';
|
||||
|
||||
import {
|
||||
CloudChannelConfig,
|
||||
@ -210,21 +210,39 @@ function grafanaChannelConfigToFormChannelValues(
|
||||
disableResolveMessage: channel.disableResolveMessage,
|
||||
};
|
||||
|
||||
// work around https://github.com/grafana/alerting-squad/issues/100
|
||||
notifier?.options.forEach((option) => {
|
||||
if (option.secure && values.secureSettings[option.propertyName]) {
|
||||
delete values.settings[option.propertyName];
|
||||
values.secureFields[option.propertyName] = true;
|
||||
}
|
||||
if (option.secure && values.settings[option.propertyName]) {
|
||||
values.secureSettings[option.propertyName] = values.settings[option.propertyName];
|
||||
delete values.settings[option.propertyName];
|
||||
}
|
||||
});
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all keys that should be marked a secure fields, using JSONpath for nested fields.
|
||||
*/
|
||||
export function getSecureFieldNames(notifier: NotifierDTO): string[] {
|
||||
// eg. ['foo', 'bar.baz']
|
||||
const secureFieldPaths: string[] = [];
|
||||
|
||||
// we'll pass in the prefix for each iteration so we can track the JSON path
|
||||
function findSecureOptions(options: NotificationChannelOption[], prefix?: string) {
|
||||
for (const option of options) {
|
||||
const key = prefix ? `${prefix}.${option.propertyName}` : option.propertyName;
|
||||
|
||||
// if the field is a subform, recurse
|
||||
if (option.subformOptions) {
|
||||
findSecureOptions(option.subformOptions, key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (option.secure) {
|
||||
secureFieldPaths.push(key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findSecureOptions(notifier.options);
|
||||
|
||||
return secureFieldPaths;
|
||||
}
|
||||
|
||||
export function formChannelValuesToGrafanaChannelConfig(
|
||||
values: GrafanaChannelValues,
|
||||
defaults: GrafanaChannelValues,
|
||||
@ -245,13 +263,21 @@ export function formChannelValuesToGrafanaChannelConfig(
|
||||
};
|
||||
|
||||
// find all secure field definitions
|
||||
const secureFieldNames: string[] =
|
||||
notifier?.options.filter((option) => option.secure).map((option) => option.propertyName) ?? [];
|
||||
const secureFieldNames = notifier ? getSecureFieldNames(notifier) : [];
|
||||
|
||||
// we make sure all fields that are marked as "secure" will be moved to "SecureSettings" instead of "settings"
|
||||
const shouldBeSecure = pick(channel.settings, secureFieldNames);
|
||||
const secureSettings = reduce(
|
||||
secureFieldNames,
|
||||
(acc: Record<string, unknown> = {}, key) => {
|
||||
// the value for secure settings can come from either the "settings" (accidental) or "secureFields" if editing an existing receiver
|
||||
acc[key] = get(channel.settings, key) ?? get(values.secureFields, key);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
channel.secureSettings = {
|
||||
...shouldBeSecure,
|
||||
...secureSettings,
|
||||
...channel.secureSettings,
|
||||
};
|
||||
|
||||
@ -291,7 +317,7 @@ export function omitEmptyValues<T>(obj: T): T {
|
||||
// Will remove empty ('', null, undefined) object properties unless they were previously defined.
|
||||
// existing is a map of property names that were previously defined.
|
||||
export function omitEmptyUnlessExisting(settings = {}, existing = {}): Record<string, unknown> {
|
||||
return omitBy(settings, (value, key) => isUnacceptableValue(value) && !(key in existing));
|
||||
return omitBy(settings, (value, key) => isUnacceptableValue(value) && !has(existing, key));
|
||||
}
|
||||
|
||||
export function omitTemporaryIdentifiers<T>(object: Readonly<T>): T {
|
||||
|
Loading…
Reference in New Issue
Block a user