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:
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user