Alerting: idempotent decrypt\encrypt operations on extra configs (#107592)

* add prefix to encrypted string to distinguish between unencrypted and encrypted ones

---------

Co-authored-by: Alexander Akhmetov <me@alx.cx>
This commit is contained in:
Yuri Tseretyan
2025-07-07 12:17:41 -04:00
committed by GitHub
co-authored by Alexander Akhmetov
parent d15e1ad8d0
commit c93fd3ee9e
2 changed files with 130 additions and 5 deletions
+24 -5
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@@ -14,6 +15,16 @@ import (
"github.com/grafana/grafana/pkg/services/secrets"
)
const (
// encryptedContentPrefix is a marker that identifies encrypted Alertmanager configurations.
// When this prefix is present at the beginning of a configuration string:
// 1. During encryption: It indicates the content is already encrypted and should be skipped
// 2. During decryption: It indicates the content (minus this prefix) should be base64 decoded
// and then decrypted using the secrets service
// This prefix helps maintain idempotency in encryption/decryption operations.
cryptoPrefix = "crypto_"
)
// Crypto allows decryption of Alertmanager Configuration and encryption of arbitrary payloads.
type Crypto interface {
LoadSecureSettings(ctx context.Context, orgId int64, receivers []*definitions.PostableApiReceiver) error
@@ -253,12 +264,17 @@ func NewExtraConfigsCrypto(secrets secretService) *ExtraConfigsCrypto {
func (c *ExtraConfigsCrypto) EncryptExtraConfigs(ctx context.Context, config *definitions.PostableUserConfig) error {
for i := range config.ExtraConfigs {
// If it has prefix, consider it encrypted already
if strings.HasPrefix(config.ExtraConfigs[i].AlertmanagerConfig, cryptoPrefix) {
continue
}
encryptedValue, err := c.secrets.Encrypt(ctx, []byte(config.ExtraConfigs[i].AlertmanagerConfig), secrets.WithoutScope())
if err != nil {
return fmt.Errorf("failed to encrypt extra configuration: %w", err)
}
config.ExtraConfigs[i].AlertmanagerConfig = base64.StdEncoding.EncodeToString(encryptedValue)
config.ExtraConfigs[i].AlertmanagerConfig = cryptoPrefix + base64.StdEncoding.EncodeToString(encryptedValue)
}
return nil
@@ -266,12 +282,15 @@ func (c *ExtraConfigsCrypto) EncryptExtraConfigs(ctx context.Context, config *de
func (c *ExtraConfigsCrypto) DecryptExtraConfigs(ctx context.Context, config *definitions.PostableUserConfig) error {
for i := range config.ExtraConfigs {
// Check if the config is encrypted by trying to base64 decode it
encryptedValue, err := base64.StdEncoding.DecodeString(config.ExtraConfigs[i].AlertmanagerConfig)
if err != nil {
// If it can't be base64 decoded, assume it's already decrypted and skip
// If it does not have prefix, consider it decrypted already
if !strings.HasPrefix(config.ExtraConfigs[i].AlertmanagerConfig, cryptoPrefix) {
continue
}
// Check if the config is encrypted by trying to base64 decode it
encryptedValue, err := base64.StdEncoding.DecodeString(config.ExtraConfigs[i].AlertmanagerConfig[len(cryptoPrefix):])
if err != nil {
return fmt.Errorf("failed to decode extra configuration: %w", err)
}
decryptedValue, err := c.secrets.Decrypt(ctx, encryptedValue)
if err != nil {
@@ -0,0 +1,106 @@
package notifier
import (
"context"
"encoding/base64"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
)
func TestEncryptExtraConfigs(t *testing.T) {
config := "plain-text-config"
encryptedConfig := base64.StdEncoding.EncodeToString([]byte(config))
tests := []struct {
name string
inputConfig string
expectedConfig string
}{
{
name: "Encrypts unencrypted configs",
inputConfig: config,
expectedConfig: cryptoPrefix + encryptedConfig,
},
{
name: "Skips already encrypted configs",
inputConfig: cryptoPrefix + "very-encrypted-data",
expectedConfig: cryptoPrefix + "very-encrypted-data",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := fakes.NewFakeSecretsService()
c := &alertmanagerCrypto{
secrets: m,
}
cfg := &definitions.PostableUserConfig{
ExtraConfigs: []definitions.ExtraConfiguration{
{AlertmanagerConfig: tt.inputConfig},
},
}
err := c.EncryptExtraConfigs(context.Background(), cfg)
require.NoError(t, err)
require.Equal(t, tt.expectedConfig, cfg.ExtraConfigs[0].AlertmanagerConfig)
})
}
}
func TestDecryptExtraConfigs(t *testing.T) {
decryptedData := "derypted-data"
decryptedDataBase64 := base64.StdEncoding.EncodeToString([]byte(decryptedData))
tests := []struct {
name string
inputConfig string
expectedError string
expectedConfig string
}{
{
name: "Decrypts encrypted configs",
inputConfig: cryptoPrefix + decryptedDataBase64,
expectedConfig: decryptedData,
},
{
name: "Skips already encrypted configs",
inputConfig: "very-decrypted-data",
expectedConfig: "very-decrypted-data",
},
{
name: "Fails if not base64 encoded",
inputConfig: cryptoPrefix + "plain-text-config",
expectedError: "failed to decode extra configuration",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := fakes.NewFakeSecretsService()
c := &alertmanagerCrypto{
secrets: m,
}
cfg := &definitions.PostableUserConfig{
ExtraConfigs: []definitions.ExtraConfiguration{
{AlertmanagerConfig: tt.inputConfig},
},
}
err := c.DecryptExtraConfigs(context.Background(), cfg)
if tt.expectedError != "" {
require.ErrorContains(t, err, tt.expectedError)
return
}
require.NoError(t, err)
require.Equal(t, tt.expectedConfig, cfg.ExtraConfigs[0].AlertmanagerConfig)
})
}
}