Alerting: Protected fields for Contact points (#115442)

* Alerting: Protect sensitive fields of contact points from
 unauthorized modification

- Introduce a new permission alert.notifications.receivers.protected:write. The permission is granted to contact point administrators.
- Introduce field Protected to NotifierOption
- Introduce DiffReport for models.Integrations with focus on Settings. The diff report is extended with methods that return all keys that are different between two settings.
- Add new annotation 'grafana.com/access/CanModifyProtected' to Receiver model
- Update receiver service to enforce the permission and return status 403 if unauthorized user modifies protected field
- Update receiver testing API to enforce permission and return status 403 if unauthorized user modifies protected field.
- Update UI to disable protected fields if user cannot modify them
This commit is contained in:
Yuri Tseretyan
2025-12-16 15:56:02 -05:00
committed by GitHub
parent 30fb1c032a
commit f2c30cbbd1
37 changed files with 1482 additions and 114 deletions
+6 -4
View File
@@ -9,10 +9,11 @@ import (
type ReceiverPermission string
const (
ReceiverPermissionReadSecret ReceiverPermission = "secrets"
ReceiverPermissionAdmin ReceiverPermission = "admin"
ReceiverPermissionWrite ReceiverPermission = "write"
ReceiverPermissionDelete ReceiverPermission = "delete"
ReceiverPermissionReadSecret ReceiverPermission = "secrets"
ReceiverPermissionAdmin ReceiverPermission = "admin"
ReceiverPermissionWrite ReceiverPermission = "write"
ReceiverPermissionDelete ReceiverPermission = "delete"
ReceiverPermissionModifyProtected ReceiverPermission = "modify-protected"
)
// ReceiverPermissions returns all possible silence permissions.
@@ -22,6 +23,7 @@ func ReceiverPermissions() []ReceiverPermission {
ReceiverPermissionAdmin,
ReceiverPermissionWrite,
ReceiverPermissionDelete,
ReceiverPermissionModifyProtected,
}
}
@@ -0,0 +1,230 @@
package models
import (
"fmt"
"reflect"
"strings"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/grafana/alerting/receivers/schema"
"github.com/grafana/grafana/pkg/util/cmputil"
)
type IntegrationDiffReport struct {
cmputil.DiffReport
}
// expandPaths recursively collects all sub-paths for keys in the provided map value
func (r IntegrationDiffReport) expandPaths(basePath schema.IntegrationFieldPath, mapVal reflect.Value) []schema.IntegrationFieldPath {
result := make([]schema.IntegrationFieldPath, 0)
iter := mapVal.MapRange()
for iter.Next() {
keyStr := fmt.Sprintf("%v", iter.Key()) // Assume string keys
p := basePath.With(keyStr)
// Recurse if the sub-value is another map
if m, ok := r.getMap(iter.Value()); ok {
result = append(result, r.expandPaths(p, m)...)
continue
}
result = append(result, p)
}
return result
}
func (r IntegrationDiffReport) getMap(v reflect.Value) (reflect.Value, bool) {
if v.Kind() == reflect.Map {
return v, true
}
if v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface {
return r.getMap(v.Elem())
}
return reflect.Value{}, false
}
func (r IntegrationDiffReport) needExpand(diff cmputil.Diff) (reflect.Value, bool) {
ml, lok := r.getMap(diff.Left)
mr, rok := r.getMap(diff.Right)
if lok == rok {
return reflect.Value{}, false
}
if lok {
return ml, true
}
return mr, true
}
func (r IntegrationDiffReport) GetSettingsPaths() []schema.IntegrationFieldPath {
diffs := r.GetDiffsForField("Settings")
paths := make([]schema.IntegrationFieldPath, 0, len(diffs))
for _, diff := range diffs {
// diff.Path has format like Settings[url] or Settings[sub-form][field]
p := diff.Path
var path schema.IntegrationFieldPath
for {
start := strings.Index(p, "[")
if start == -1 {
break
}
p = p[start+1:]
end := strings.Index(p, "]")
if end == -1 {
break
}
fieldName := p[:end]
p = p[end+1:]
path = append(path, fieldName)
}
if m, ok := r.needExpand(diff); ok {
paths = append(paths, r.expandPaths(path, m)...)
continue
}
if len(path) > 0 {
paths = append(paths, path)
}
}
return paths
}
func (r IntegrationDiffReport) GetSecureSettingsPaths() []schema.IntegrationFieldPath {
diffs := r.GetDiffsForField("SecureSettings")
paths := make([]schema.IntegrationFieldPath, 0, len(diffs))
for _, diff := range diffs {
if diff.Path == "SecureSettings" {
if m, ok := r.needExpand(diff); ok {
paths = append(paths, r.expandPaths(nil, m)...)
}
continue
}
// diff.Path has format like SecureSettings[field.sub-field.sub]
p := schema.ParseIntegrationPath(diff.Path[len("SecureSettings[") : len(diff.Path)-1])
paths = append(paths, p)
}
return paths
}
func (integration *Integration) Diff(incoming Integration) IntegrationDiffReport {
var reporter cmputil.DiffReporter
var settingsCmp = cmpopts.AcyclicTransformer("settingsMap", func(in map[string]any) map[string]any {
if in == nil {
return map[string]any{}
}
return in
})
var secureCmp = cmpopts.AcyclicTransformer("secureMap", func(in map[string]string) map[string]string {
if in == nil {
return map[string]string{}
}
return in
})
schemaCmp := cmp.Comparer(func(a, b schema.IntegrationSchemaVersion) bool {
isAZero := reflect.ValueOf(a).IsZero()
isBZero := reflect.ValueOf(b).IsZero()
if isAZero && isBZero {
return true
}
if isAZero || isBZero {
return false
}
return a.Type() == b.Type() && a.Version == b.Version
})
var cur Integration
if integration != nil {
cur = *integration
}
cmp.Equal(cur, incoming, cmp.Reporter(&reporter), settingsCmp, secureCmp, schemaCmp)
return IntegrationDiffReport{DiffReport: reporter.Diffs}
}
// HasReceiversDifferentProtectedFields returns true if the receiver has any protected fields that are different from the incoming receiver.
func HasReceiversDifferentProtectedFields(existing, incoming *Receiver) map[string][]schema.IntegrationFieldPath {
existingIntegrations := make(map[string]*Integration, len(existing.Integrations))
for _, integration := range existing.Integrations {
existingIntegrations[integration.UID] = integration
}
var result = make(map[string][]schema.IntegrationFieldPath)
for _, in := range incoming.Integrations {
if in.UID == "" {
continue
}
ex, ok := existingIntegrations[in.UID]
if !ok {
continue
}
paths := HasIntegrationsDifferentProtectedFields(ex, in)
if len(paths) > 0 {
result[in.UID] = paths
}
}
return result
}
// HasIntegrationsDifferentProtectedFields returns list of paths to protected fields that are different between two integrations.
func HasIntegrationsDifferentProtectedFields(existing, incoming *Integration) []schema.IntegrationFieldPath {
diff := existing.Diff(*incoming)
// The incoming receiver always has both secret and non-secret fields in Settings.
// So, if it's specified and happens to be sensitive, we consider it changed
var result []schema.IntegrationFieldPath
settingsDiff := diff.GetSettingsPaths()
for _, path := range settingsDiff {
if IsProtectedField(incoming.Config.Type(), path) {
result = append(result, path)
}
}
return result
}
// IsProtectedField returns true if the field at the given path is existing protected one.
// This includes:
// 1. URL fields marked as secure in the schema (e.g., webhook URLs with credentials)
// 2. URL fields NOT marked as secure but could contain credentials (e.g., API endpoints)
func IsProtectedField(integrationType schema.IntegrationType, path schema.IntegrationFieldPath) bool {
str := strings.ToLower(string(integrationType))
pathStr := path.String()
switch str {
case "prometheus-alertmanager":
return pathStr == "url"
case "dingding":
return pathStr == "url" // marked as secure
case "discord":
return pathStr == "url" // marked as secure (webhook URL)
case "googlechat":
return pathStr == "url" // marked as secure
case "jira":
return pathStr == "api_url"
case "kafka":
return pathStr == "kafkaRestProxy"
case "line":
return false
case "mqtt":
return pathStr == "brokerUrl"
case "oncall":
return pathStr == "url"
case "opsgenie":
return pathStr == "apiUrl"
case "pagerduty":
return pathStr == "url"
case "sensugo":
return pathStr == "url"
case "slack":
return pathStr == "url" || pathStr == "endpointUrl"
case "teams":
return pathStr == "url"
case "victorops":
return pathStr == "url" // marked as secure
case "webex":
return pathStr == "api_url"
case "webhook":
return pathStr == "url" ||
pathStr == "http_config.oauth2.token_url" ||
pathStr == "http_config.oauth2.proxy_config.proxy_url"
case "wecom":
return pathStr == "url" || // marked as secure
pathStr == "endpointUrl"
default:
return false
}
}
@@ -0,0 +1,262 @@
package models
import (
"slices"
"testing"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/alerting/receivers/schema"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIntegrationDiff(t *testing.T) {
s, _ := alertingNotify.GetSchemaVersionForIntegration("webhook", schema.V1)
a := Integration{
UID: "test-uid",
Name: "test-name",
Config: s,
DisableResolveMessage: false,
Settings: map[string]any{
"url": "http://localhost",
"name": 123,
"flag": true,
"child": map[string]any{
"sub-form-field": "test",
},
},
SecureSettings: map[string]string{
"password": "12345",
"token": "token-12345",
},
}
t.Run("no diff if equal", func(t *testing.T) {
result := a.Diff(a)
assert.Empty(t, result)
})
t.Run("should deep compare settings", func(t *testing.T) {
b := a
b.Settings = map[string]any{
"url": "http://localhost:123",
"flag": false,
"child": map[string]any{
"sub-form-field": "test123",
"sub-child": map[string]any{
"test": "test",
},
},
}
result := a.Diff(b)
assert.ElementsMatch(t,
[]string{"Settings[url]", "Settings[name]", "Settings[flag]", "Settings[child][sub-form-field]", "Settings[child][sub-child]"},
result.Paths())
})
t.Run("should shallow compare schemas", func(t *testing.T) {
b := a
b.Config, _ = alertingNotify.GetSchemaVersionForIntegration("slack", schema.V1)
result := a.Diff(b)
assert.ElementsMatch(t,
[]string{"Config"},
result.Paths())
})
t.Run("should compare with zero objects", func(t *testing.T) {
result := a.Diff(Integration{})
assert.ElementsMatch(t,
[]string{
"UID",
"Name",
"Config",
"Settings[child]",
"Settings[flag]",
"Settings[name]",
"Settings[url]",
"SecureSettings[password]",
"SecureSettings[token]",
},
result.Paths())
})
}
func TestIntegrationDiffReport_GetSettingsPaths(t *testing.T) {
a := Integration{
UID: "test-uid",
Name: "test-name",
Config: schema.IntegrationSchemaVersion{},
DisableResolveMessage: false,
Settings: map[string]any{
"url": "http://localhost",
"child": map[string]any{
"field": "test",
"sub-child": map[string]any{
"test": "test",
},
},
},
}
testCases := []struct {
name string
left map[string]any
right map[string]any
paths []string
}{
{
name: "empty",
left: map[string]any{},
right: map[string]any{},
},
{
name: "left is empty",
left: map[string]any{},
right: map[string]any{
"field": "test",
},
paths: []string{"field"},
},
{
name: "right is empty",
left: map[string]any{
"field": "test",
},
right: map[string]any{},
paths: []string{"field"},
},
{
name: "expands nested",
left: map[string]any{
"field": map[string]any{
"sub-field": map[string]any{
"test": "test",
},
},
},
right: map[string]any{
"another": map[string]any{
"sub-field": map[string]any{
"test": "test",
},
},
},
paths: []string{
"field.sub-field.test",
"another.sub-field.test",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
b := a
b.Settings = tc.right
a.Settings = tc.left
diff := a.Diff(b)
actual := diff.GetSettingsPaths()
actualStrings := make([]string, 0, len(actual))
for _, f := range actual {
actualStrings = append(actualStrings, f.String())
}
assert.ElementsMatch(t, tc.paths, actualStrings)
})
}
}
func TestHasDifferentProtectedFields(t *testing.T) {
m := IntegrationMuts
testCase := []struct {
name string
existing Integration
incoming Integration
expected map[string][]string
}{
{
name: "different UID do not match",
existing: IntegrationGen(m.WithUID("existing"), m.WithValidConfig("webhook"))(),
incoming: IntegrationGen(
m.WithValidConfig("webhook"),
m.AddSetting("url", "http://some-other-url"),
m.WithUID("incoming"),
)(),
expected: nil,
},
{
name: "find url protected",
existing: IntegrationGen(m.WithUID("1"), m.WithValidConfig("webhook"))(),
incoming: IntegrationGen(
m.WithValidConfig("webhook"),
m.AddSetting("url", "http://some-other-url"),
m.AddSetting("http_config", map[string]any{
"oauth2": map[string]any{
"proxy_config": map[string]any{
"proxy_url": "http://some-other-url-proxy",
},
"token_url": "http://some-other-url-token",
},
}),
m.WithUID("1"),
)(),
expected: map[string][]string{
"1": {
"http_config.oauth2.proxy_config.proxy_url",
"http_config.oauth2.token_url",
"url",
},
},
},
{
name: "secure and protected", // simulate the situation when protected secured field is in secure settings but the incoming one has it in settings
existing: IntegrationGen(
m.WithUID("1"),
m.WithValidConfig("discord"),
m.RemoveSetting("url"),
m.WithSecureSettings(map[string]string{
"url": "<SECURED>",
}))(),
incoming: IntegrationGen(
m.WithValidConfig("discord"),
m.AddSetting("url", "http://some-other-url"),
m.WithSecureSettings(nil),
m.WithUID("1"),
)(),
expected: map[string][]string{
"1": {
"url",
},
},
},
}
for _, tc := range testCase {
t.Run(tc.name, func(t *testing.T) {
existing := &Receiver{
Integrations: []*Integration{
&tc.existing,
},
}
incoming := &Receiver{
Integrations: []*Integration{
&tc.incoming,
},
}
actual := HasReceiversDifferentProtectedFields(existing, incoming)
if len(tc.expected) == 0 {
require.Empty(t, actual)
return
}
actualStrings := make(map[string][]string, len(actual))
for uid, paths := range actual {
for _, path := range paths {
actualStrings[uid] = append(actualStrings[uid], path.String())
}
slices.Sort(actualStrings[uid])
}
assert.EqualValues(t, tc.expected, actualStrings)
})
}
}
+6
View File
@@ -1457,6 +1457,12 @@ func (n IntegrationMutators) AddSecureSetting(key, val string) Mutator[Integrati
}
}
func (n IntegrationMutators) RemoveSetting(key string) Mutator[Integration] {
return func(c *Integration) {
delete(c.Settings, key)
}
}
func randomMapKey[K comparable, V any](m map[K]V) (K, V) {
randIdx := rand.Intn(len(m))
i := 0