[release-12.0.8] Alerting: Fix contact points issue (#115410)

* 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 APIs to enforce permission and return status 403 if unauthorized user modifies protected field.
- Update UI to disable protected fields if user cannot modify them

Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com>

* fix linter error

* prettier:write

---------

Co-authored-by: Yuri Tseretyan <yuriy.tseretyan@grafana.com>
Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com>
This commit is contained in:
Kevin Minehart
2025-12-16 16:00:54 +00:00
committed by GitHub
co-authored by Sonia Aguilar Yuri Tseretyan
parent 7d45c11085
commit 0049ab9c1e
48 changed files with 1939 additions and 427 deletions
+3 -17
View File
@@ -1126,12 +1126,7 @@ 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, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "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 in text props. Wrap text with <Trans /> or use t()", "0"]
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
@@ -1142,12 +1137,6 @@ exports[`better eslint`] = {
"public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
],
"public/app/features/alerting/unified/components/receivers/form/TestContactPointModal.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
@@ -1156,9 +1145,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],
[0, 0, 0, "Unexpected any. Specify a different type.", "2"],
[0, 0, 0, "Unexpected any. Specify a different type.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
[0, 0, 0, "Unexpected any. Specify a different type.", "3"]
],
"public/app/features/alerting/unified/components/receivers/form/fields/SubformArrayField.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with <Trans /> or use t()", "0"],
@@ -1378,8 +1365,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
],
"public/app/features/alerting/unified/types/receiver-form.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/alerting/unified/utils/misc.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
@@ -105,10 +105,11 @@ func convertToK8sResource(
}
var permissionMapper = map[ngmodels.ReceiverPermission]string{
ngmodels.ReceiverPermissionReadSecret: "canReadSecrets",
ngmodels.ReceiverPermissionAdmin: "canAdmin",
ngmodels.ReceiverPermissionWrite: "canWrite",
ngmodels.ReceiverPermissionDelete: "canDelete",
ngmodels.ReceiverPermissionReadSecret: "canReadSecrets",
ngmodels.ReceiverPermissionAdmin: "canAdmin",
ngmodels.ReceiverPermissionWrite: "canWrite",
ngmodels.ReceiverPermissionDelete: "canDelete",
ngmodels.ReceiverPermissionModifyProtected: "canModifyProtected",
}
func convertToDomainModel(receiver *model.Receiver) (*ngmodels.Receiver, map[string][]string, error) {
+1
View File
@@ -458,6 +458,7 @@ const (
ActionAlertingReceiversReadSecrets = "alert.notifications.receivers.secrets:read"
ActionAlertingReceiversCreate = "alert.notifications.receivers:create"
ActionAlertingReceiversUpdate = "alert.notifications.receivers:write"
ActionAlertingReceiversUpdateProtected = "alert.notifications.receivers.protected:write"
ActionAlertingReceiversDelete = "alert.notifications.receivers:delete"
ActionAlertingReceiversTest = "alert.notifications.receivers:test"
ActionAlertingReceiversPermissionsRead = "receivers.permissions:read"
@@ -24,7 +24,7 @@ import (
var ReceiversViewActions = []string{accesscontrol.ActionAlertingReceiversRead}
var ReceiversEditActions = append(ReceiversViewActions, []string{accesscontrol.ActionAlertingReceiversUpdate, accesscontrol.ActionAlertingReceiversDelete}...)
var ReceiversAdminActions = append(ReceiversEditActions, []string{accesscontrol.ActionAlertingReceiversReadSecrets, accesscontrol.ActionAlertingReceiversPermissionsRead, accesscontrol.ActionAlertingReceiversPermissionsWrite}...)
var ReceiversAdminActions = append(ReceiversEditActions, []string{accesscontrol.ActionAlertingReceiversReadSecrets, accesscontrol.ActionAlertingReceiversPermissionsRead, accesscontrol.ActionAlertingReceiversPermissionsWrite, accesscontrol.ActionAlertingReceiversUpdateProtected}...)
// defaultPermissions returns the default permissions for a newly created receiver.
func defaultPermissions() []accesscontrol.SetResourcePermissionCommand {
+2 -1
View File
@@ -290,12 +290,13 @@ var (
Role: accesscontrol.RoleDTO{
Name: accesscontrol.FixedRolePrefix + "alerting:admin",
DisplayName: "Full admin access",
Description: "Full write access in Grafana and all external providers, including their permissions and secrets",
Description: "Full write access in Grafana and all external providers, including their permissions, protected fields and secrets",
Group: AlertRolesGroup,
Permissions: accesscontrol.ConcatPermissions(alertingWriterRole.Role.Permissions, []accesscontrol.Permission{
{Action: accesscontrol.ActionAlertingReceiversPermissionsRead, Scope: ac.ScopeReceiversAll},
{Action: accesscontrol.ActionAlertingReceiversPermissionsWrite, Scope: ac.ScopeReceiversAll},
{Action: accesscontrol.ActionAlertingReceiversReadSecrets, Scope: ac.ScopeReceiversAll},
{Action: accesscontrol.ActionAlertingReceiversUpdateProtected, Scope: ac.ScopeReceiversAll},
}),
},
Grants: []string{string(org.RoleAdmin)},
@@ -137,6 +137,26 @@ var (
)
}
// Asserts pre-conditions for access to modify protected fields of receivers. If this evaluates to false, the user cannot modify protected fields of any receivers.
updateReceiversProtectedPreConditionsEval = ac.EvalAll(
updateReceiversPreConditionsEval,
ac.EvalPermission(ac.ActionAlertingReceiversUpdateProtected), // Action for receivers. UID scope.
)
// Asserts access to modify protected fields of a specific receiver.
updateReceiverProtectedEval = func(uid string) ac.Evaluator {
return ac.EvalAll(
updateReceiverEval(uid),
ac.EvalPermission(ac.ActionAlertingReceiversUpdateProtected, ScopeReceiversProvider.GetResourceScopeUID(uid)),
)
}
// Asserts access to modify protected fields of all receivers.
updateAllReceiverProtectedEval = ac.EvalAll(
updateAllReceiversEval,
ac.EvalPermission(ac.ActionAlertingReceiversUpdateProtected, ScopeReceiversAll),
)
// Delete
// Asserts pre-conditions for delete access to receivers. If this evaluates to false, the user cannot delete any receivers.
@@ -183,12 +203,13 @@ var (
)
type ReceiverAccess[T models.Identified] struct {
read actionAccess[T]
readDecrypted actionAccess[T]
create actionAccess[T]
update actionAccess[T]
delete actionAccess[T]
permissions actionAccess[T]
read actionAccess[T]
readDecrypted actionAccess[T]
create actionAccess[T]
update actionAccess[T]
updateProtected actionAccess[T]
delete actionAccess[T]
permissions actionAccess[T]
}
// NewReceiverAccess creates a new ReceiverAccess service. If includeProvisioningActions is true, the service will include
@@ -243,6 +264,18 @@ func NewReceiverAccess[T models.Identified](a ac.AccessControl, includeProvision
},
authorizeAll: updateAllReceiversEval,
},
updateProtected: actionAccess[T]{
genericService: genericService{
ac: a,
},
resource: "receiver",
action: "update protected fields of", // this produces message "user is not authorized to update protected fields of X receiver"
authorizeSome: updateReceiversProtectedPreConditionsEval,
authorizeOne: func(receiver models.Identified) ac.Evaluator {
return updateReceiverProtectedEval(receiver.GetUID())
},
authorizeAll: updateAllReceiverProtectedEval,
},
delete: actionAccess[T]{
genericService: genericService{
ac: a,
@@ -353,6 +386,14 @@ func (s ReceiverAccess[T]) AuthorizeUpdate(ctx context.Context, user identity.Re
return s.update.Authorize(ctx, user, receiver)
}
func (s ReceiverAccess[T]) HasUpdateProtected(ctx context.Context, user identity.Requester, receiver T) (bool, error) {
return s.updateProtected.Has(ctx, user, receiver)
}
func (s ReceiverAccess[T]) AuthorizeUpdateProtected(ctx context.Context, user identity.Requester, receiver T) error {
return s.updateProtected.Authorize(ctx, user, receiver)
}
// Global
// AuthorizeCreate checks if user has access to create receivers. Returns an error if user does not have access.
@@ -422,6 +463,12 @@ func (s ReceiverAccess[T]) Access(ctx context.Context, user identity.Requester,
basePerms.Set(models.ReceiverPermissionDelete, true) // Has access to all receivers.
}
if err := s.updateProtected.AuthorizePreConditions(ctx, user); err != nil {
basePerms.Set(models.ReceiverPermissionModifyProtected, false)
} else if err := s.updateProtected.AuthorizeAll(ctx, user); err == nil {
basePerms.Set(models.ReceiverPermissionModifyProtected, true)
}
if basePerms.AllSet() {
// Shortcut for the case when all permissions are known based on preconditions.
result := make(map[string]models.ReceiverPermissionSet, len(receivers))
@@ -454,6 +501,11 @@ func (s ReceiverAccess[T]) Access(ctx context.Context, user identity.Requester,
permSet.Set(models.ReceiverPermissionDelete, err == nil)
}
if _, ok := permSet.Has(models.ReceiverPermissionModifyProtected); !ok {
err := s.updateProtected.authorize(ctx, user, rcv)
permSet.Set(models.ReceiverPermissionModifyProtected, err == nil)
}
result[rcv.GetUID()] = permSet
}
return result, nil
@@ -132,7 +132,7 @@ func TestReceiverAccess(t *testing.T) {
recv3.UID: permissions(),
},
},
//{
// {
// name: "legacy global notifications provisioning writer should have full write on provisioning only",
// user: newViewUser(ac.Permission{Action: ac.ActionAlertingNotificationsProvisioningWrite}),
// expected: map[string]models.ReceiverPermissionSet{
@@ -145,8 +145,8 @@ func TestReceiverAccess(t *testing.T) {
// recv2.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionDelete),
// recv3.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionDelete),
// },
//},
//{
// },
// {
// name: "legacy global provisioning writer should have full write on provisioning only",
// user: newViewUser(ac.Permission{Action: ac.ActionAlertingProvisioningWrite}),
// expected: map[string]models.ReceiverPermissionSet{
@@ -159,7 +159,7 @@ func TestReceiverAccess(t *testing.T) {
// recv2.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionDelete),
// recv3.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionDelete),
// },
//},
// },
// Receiver create
{
name: "receiver create should not have write",
@@ -204,6 +204,33 @@ func TestReceiverAccess(t *testing.T) {
recv3.UID: permissions(),
},
},
{
name: "update protected cannot update receivers",
user: newEmptyUser(
ac.Permission{Action: ac.ActionAlertingReceiversRead, Scope: ScopeReceiversAll},
ac.Permission{Action: ac.ActionAlertingReceiversUpdateProtected, Scope: ScopeReceiversAll},
),
expected: map[string]models.ReceiverPermissionSet{
recv1.UID: permissions(),
recv2.UID: permissions(),
recv3.UID: permissions(),
},
},
{
name: "update protected receivers",
user: newEmptyUser(
ac.Permission{Action: ac.ActionAlertingReceiversRead, Scope: ScopeReceiversAll},
ac.Permission{Action: ac.ActionAlertingReceiversUpdateProtected, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv1.UID)},
ac.Permission{Action: ac.ActionAlertingReceiversUpdate, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv1.UID)},
ac.Permission{Action: ac.ActionAlertingReceiversUpdate, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv2.UID)},
ac.Permission{Action: ac.ActionAlertingReceiversUpdateProtected, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv3.UID)},
),
expected: map[string]models.ReceiverPermissionSet{
recv1.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionModifyProtected),
recv2.UID: permissions(models.ReceiverPermissionWrite),
recv3.UID: permissions(),
},
},
// Receiver delete.
{
name: "global receiver delete should have delete but no write",
+5 -1
View File
@@ -18,6 +18,7 @@ import (
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
apimodels "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/legacy_storage"
"github.com/grafana/grafana/pkg/services/ngalert/store"
@@ -32,6 +33,7 @@ const (
type receiversAuthz interface {
FilterRead(ctx context.Context, user identity.Requester, receivers ...ReceiverStatus) ([]ReceiverStatus, error)
AuthorizeUpdateProtected(context.Context, identity.Requester, ReceiverStatus) error
}
type AlertmanagerSrv struct {
@@ -210,7 +212,9 @@ func (srv AlertmanagerSrv) RouteGetReceivers(c *contextmodel.ReqContext) respons
}
func (srv AlertmanagerSrv) RoutePostTestReceivers(c *contextmodel.ReqContext, body apimodels.TestReceiversConfigBodyParams) response.Response {
if err := srv.crypto.ProcessSecureSettings(c.Req.Context(), c.GetOrgID(), body.Receivers); err != nil {
if err := srv.crypto.ProcessSecureSettings(c.Req.Context(), c.GetOrgID(), body.Receivers, func(receiverName string, paths []models.IntegrationFieldPath) error {
return srv.receiverAuthz.AuthorizeUpdateProtected(c.Req.Context(), c.SignedInUser, ReceiverStatus{Name: receiverName})
}); err != nil {
var unknownReceiverError UnknownReceiverError
if errors.As(err, &unknownReceiverError) {
return ErrResp(http.StatusBadRequest, err, "")
+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,
}
}
+179 -10
View File
@@ -8,13 +8,18 @@ import (
"fmt"
"maps"
"math"
"reflect"
"slices"
"sort"
"strings"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
"github.com/grafana/grafana/pkg/util/cmputil"
)
// GetReceiverQuery represents a query for a single receiver.
@@ -161,9 +166,10 @@ type IntegrationConfig struct {
// IntegrationField represents a field in an integration configuration.
type IntegrationField struct {
Name string
Fields map[string]IntegrationField
Secure bool
Name string
Fields map[string]IntegrationField
Secure bool
Protected bool
}
type IntegrationFieldPath []string
@@ -192,7 +198,11 @@ func (f IntegrationFieldPath) String() string {
}
func (f IntegrationFieldPath) Append(segment string) IntegrationFieldPath {
return append(f, segment)
// Copy the existing path to avoid modifying the original slice.
newPath := make(IntegrationFieldPath, len(f)+1)
copy(newPath, f)
newPath[len(newPath)-1] = segment
return newPath
}
// IntegrationConfigFromType returns an integration configuration for a given integration type. If the integration type is
@@ -213,9 +223,10 @@ func IntegrationConfigFromType(integrationType string) (IntegrationConfig, error
func notifierOptionToIntegrationField(option channels_config.NotifierOption) IntegrationField {
f := IntegrationField{
Name: option.PropertyName,
Secure: option.Secure,
Fields: make(map[string]IntegrationField, len(option.SubformOptions)),
Name: option.PropertyName,
Secure: option.Secure,
Protected: option.Protected,
Fields: make(map[string]IntegrationField, len(option.SubformOptions)),
}
for _, subformOption := range option.SubformOptions {
f.Fields[subformOption.PropertyName] = notifierOptionToIntegrationField(subformOption)
@@ -288,9 +299,10 @@ func (field *IntegrationField) GetField(path IntegrationFieldPath) (IntegrationF
func (field *IntegrationField) Clone() IntegrationField {
f := IntegrationField{
Name: field.Name,
Secure: field.Secure,
Fields: make(map[string]IntegrationField, len(field.Fields)),
Name: field.Name,
Secure: field.Secure,
Fields: make(map[string]IntegrationField, len(field.Fields)),
Protected: field.Protected,
}
for subName, sub := range field.Fields {
f.Fields[subName] = sub.Clone()
@@ -653,3 +665,160 @@ func writeSettings(f fingerprint, m map[string]any) {
}
}
}
type IntegrationDiffReport struct {
cmputil.DiffReport
}
// expandPaths recursively collects all sub-paths for keys in the provided map value
func (r IntegrationDiffReport) expandPaths(basePath IntegrationFieldPath, mapVal reflect.Value) []IntegrationFieldPath {
result := make([]IntegrationFieldPath, 0)
iter := mapVal.MapRange()
for iter.Next() {
keyStr := fmt.Sprintf("%v", iter.Key()) // Assume string keys
p := basePath.Append(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() []IntegrationFieldPath {
diffs := r.GetDiffsForField("Settings")
paths := make([]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 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() []IntegrationFieldPath {
diffs := r.GetDiffsForField("SecureSettings")
paths := make([]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 := NewIntegrationFieldPath(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 IntegrationConfig) bool {
return a.Type == b.Type
})
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][]IntegrationFieldPath {
existingIntegrations := make(map[string]*Integration, len(existing.Integrations))
for _, integration := range existing.Integrations {
existingIntegrations[integration.UID] = integration
}
var result = make(map[string][]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) []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 []IntegrationFieldPath
settingsDiff := diff.GetSettingsPaths()
for _, path := range settingsDiff {
f, _ := incoming.Config.GetField(path)
if f.Protected {
result = append(result, path)
}
}
return result
}
@@ -2,6 +2,7 @@ package models
import (
"reflect"
"slices"
"testing"
alertingNotify "github.com/grafana/alerting/notify"
@@ -408,3 +409,244 @@ func TestReceiver_Fingerprint(t *testing.T) {
}
})
}
func TestIntegrationDiff(t *testing.T) {
s := IntegrationConfig{Type: "test"}
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 = IntegrationConfig{Type: "test2"}
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: IntegrationConfig{},
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.WithUID("1"),
)(),
expected: map[string][]string{
"1": {
"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
@@ -1386,3 +1386,9 @@ func ConvertToRecordingRule(rule *AlertRule) {
func nameToUid(name string) string { // Avoid legacy_storage.NameToUid import cycle.
return base64.RawURLEncoding.EncodeToString([]byte(name))
}
func (n IntegrationMutators) RemoveSetting(key string) Mutator[Integration] {
return func(c *Integration) {
delete(c.Settings, key)
}
}
@@ -293,7 +293,7 @@ func (moa *MultiOrgAlertmanager) SaveAndApplyAlertmanagerConfiguration(ctx conte
}
cleanPermissionsErr := err
if err := moa.Crypto.ProcessSecureSettings(ctx, org, config.AlertmanagerConfig.Receivers); err != nil {
if err := moa.Crypto.ProcessSecureSettings(ctx, org, config.AlertmanagerConfig.Receivers, nil); err != nil {
return fmt.Errorf("failed to post process Alertmanager configuration: %w", err)
}
@@ -128,6 +128,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
PropertyName: "url",
Required: true,
Secure: true,
Protected: true,
},
{
Label: "Message Type",
@@ -174,6 +175,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
Placeholder: "http://localhost:8082",
PropertyName: "kafkaRestProxy",
Required: true,
Protected: true,
},
{
Label: "Topic",
@@ -374,6 +376,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
InputType: InputTypeText,
Placeholder: alertingPagerduty.DefaultURL,
PropertyName: "url",
Protected: true,
},
},
},
@@ -391,6 +394,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
PropertyName: "url",
Required: true,
Secure: true,
Protected: true,
},
{ // New in 8.0.
Label: "Message Type",
@@ -436,6 +440,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
InputType: InputTypeText,
PropertyName: "url",
Required: true,
Protected: true,
},
{
Label: "HTTP Method",
@@ -685,6 +690,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
Secure: true,
Required: true,
DependsOn: "token",
Protected: true,
},
{ // New in 8.4.
Label: "Endpoint URL",
@@ -693,6 +699,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
Description: "Optionally provide a custom Slack message API endpoint for non-webhook requests, default is https://slack.com/api/chat.postMessage",
Placeholder: "Slack endpoint url",
PropertyName: "endpointUrl",
Protected: true,
},
{
Label: "Color",
@@ -732,6 +739,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
Placeholder: "http://sensu-api.local:8080",
PropertyName: "url",
Required: true,
Protected: true,
},
{
Label: "API Key",
@@ -790,6 +798,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
Placeholder: "Teams incoming webhook url",
PropertyName: "url",
Required: true,
Protected: true,
},
{
Label: "Title",
@@ -908,6 +917,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
InputType: InputTypeText,
PropertyName: "url",
Required: true,
Protected: true,
},
{
Label: "HTTP Method",
@@ -1102,6 +1112,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
Secure: true,
Required: true,
DependsOn: "secret",
Protected: true,
},
{
Label: "Agent ID",
@@ -1187,6 +1198,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
Placeholder: "http://localhost:9093",
PropertyName: "url",
Required: true,
Protected: true,
},
{
Label: "Basic Auth User",
@@ -1233,6 +1245,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
PropertyName: "url",
Required: true,
Secure: true,
Protected: true,
},
{
Label: "Avatar URL",
@@ -1262,6 +1275,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
PropertyName: "url",
Required: true,
Secure: true,
Protected: true,
},
{
Label: "Title",
@@ -1382,6 +1396,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
Description: "The URL of the MQTT broker.",
PropertyName: "brokerUrl",
Required: true,
Protected: true,
},
{
Label: "Topic",
@@ -1539,6 +1554,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
Placeholder: "https://api.opsgenie.com/v2/alerts",
PropertyName: "apiUrl",
Required: true,
Protected: true,
},
{
Label: "Message",
@@ -1635,6 +1651,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
Placeholder: "https://api.ciscospark.com/v1/messages",
Description: "API endpoint at which we'll send webhooks to.",
PropertyName: "api_url",
Protected: true,
},
{
Label: "Room ID",
@@ -1669,7 +1686,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
Type: "sns",
Name: "AWS SNS",
Description: "Sends notifications to AWS Simple Notification Service",
Heading: "Webex settings",
Heading: "AWS SNS settings",
Options: []NotifierOption{
{
Label: "The Amazon SNS API URL",
@@ -1791,6 +1808,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
PropertyName: "api_url",
Description: "Supported v2 or v3 APIs",
Required: true,
Protected: true,
},
{
Label: "HTTP Basic Authentication - Username",
@@ -25,6 +25,7 @@ type NotifierOption struct {
Secure bool `json:"secure"`
DependsOn string `json:"dependsOn"`
SubformOptions []NotifierOption `json:"subformOptions"`
Protected bool `json:"protected"`
}
// ElementType is the type of element that can be rendered in the frontend.
+37 -7
View File
@@ -9,18 +9,21 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
"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/channels_config"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/grafana/grafana/pkg/services/secrets"
)
type AuthorizeProtectedFn func(uid string, paths []models.IntegrationFieldPath) error
// Crypto allows decryption of Alertmanager Configuration and encryption of arbitrary payloads.
type Crypto interface {
LoadSecureSettings(ctx context.Context, orgId int64, receivers []*definitions.PostableApiReceiver) error
LoadSecureSettings(ctx context.Context, orgId int64, receivers []*definitions.PostableApiReceiver, fn AuthorizeProtectedFn) error
Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error)
getDecryptedSecret(r *definitions.PostableGrafanaReceiver, key string) (string, error)
ProcessSecureSettings(ctx context.Context, orgId int64, recvs []*definitions.PostableApiReceiver) error
ProcessSecureSettings(ctx context.Context, orgId int64, recvs []*definitions.PostableApiReceiver, fn AuthorizeProtectedFn) error
}
// alertmanagerCrypto implements decryption of Alertmanager configuration and encryption of arbitrary payloads based on Grafana's encryptions.
@@ -39,7 +42,7 @@ func NewCrypto(secrets secrets.Service, configs configurationStore, log log.Logg
}
// ProcessSecureSettings encrypts new secure settings and loads existing secure settings from the database.
func (c *alertmanagerCrypto) ProcessSecureSettings(ctx context.Context, orgId int64, recvs []*definitions.PostableApiReceiver) error {
func (c *alertmanagerCrypto) ProcessSecureSettings(ctx context.Context, orgId int64, recvs []*definitions.PostableApiReceiver, authorizeProtected AuthorizeProtectedFn) error {
// First, we encrypt the new or updated secure settings. Then, we load the existing secure settings from the database
// and add back any that weren't updated.
// We perform these steps in this order to ensure the hash of the secure settings remains stable when no secure
@@ -50,7 +53,7 @@ func (c *alertmanagerCrypto) ProcessSecureSettings(ctx context.Context, orgId in
return fmt.Errorf("failed to encrypt receivers: %w", err)
}
if err := c.LoadSecureSettings(ctx, orgId, recvs); err != nil {
if err := c.LoadSecureSettings(ctx, orgId, recvs, authorizeProtected); err != nil {
return err
}
@@ -152,7 +155,7 @@ func encryptReceiverConfigs(c []*definitions.PostableApiReceiver, encrypt defini
}
// LoadSecureSettings adds the corresponding unencrypted secrets stored to the list of input receivers.
func (c *alertmanagerCrypto) LoadSecureSettings(ctx context.Context, orgId int64, receivers []*definitions.PostableApiReceiver) error {
func (c *alertmanagerCrypto) LoadSecureSettings(ctx context.Context, orgId int64, receivers []*definitions.PostableApiReceiver, authorizeProtected AuthorizeProtectedFn) error {
// Get the last known working configuration.
amConfig, err := c.configs.GetLatestAlertmanagerConfiguration(ctx, orgId)
if err != nil {
@@ -161,10 +164,10 @@ func (c *alertmanagerCrypto) LoadSecureSettings(ctx context.Context, orgId int64
return fmt.Errorf("failed to get latest configuration: %w", err)
}
}
var currentConfig *definitions.PostableUserConfig
currentReceiverMap := make(map[string]*definitions.PostableGrafanaReceiver)
if amConfig != nil {
currentConfig, err := Load([]byte(amConfig.AlertmanagerConfiguration))
currentConfig, err = Load([]byte(amConfig.AlertmanagerConfiguration))
// If the current config is un-loadable, treat it as if it never existed. Providing a new, valid config should be able to "fix" this state.
if err != nil {
c.log.Warn("Last known alertmanager configuration was invalid. Overwriting...")
@@ -194,6 +197,33 @@ func (c *alertmanagerCrypto) LoadSecureSettings(ctx context.Context, orgId int64
return UnknownReceiverError{UID: gr.UID}
}
if authorizeProtected != nil {
incoming, errIn := PostableGrafanaReceiverToIntegration(gr)
existing, errEx := PostableGrafanaReceiverToIntegration(cgmr)
var secure []models.IntegrationFieldPath
authz := true
if errIn == nil && errEx == nil {
secure = models.HasIntegrationsDifferentProtectedFields(existing, incoming)
authz = len(secure) > 0
}
// if conversion failed, consider there are changes and authorize
if authz && currentConfig != nil {
var receiverName string
NAME:
for _, rcv := range currentConfig.AlertmanagerConfig.Receivers {
for _, intg := range rcv.GrafanaManagedReceivers {
if intg.UID == cgmr.UID {
receiverName = rcv.Name
break NAME
}
}
}
if err := authorizeProtected(receiverName, secure); err != nil {
return err
}
}
}
// Frontend sends only the secure settings that have to be updated
// Therefore we have to copy from the last configuration only those secure settings not included in the request
for key, encryptedValue := range cgmr.SecureSettings {
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"slices"
"strings"
"go.opentelemetry.io/otel/attribute"
@@ -75,6 +76,9 @@ type receiverAccessControlService interface {
AuthorizeUpdate(context.Context, identity.Requester, *models.Receiver) error
AuthorizeDeleteByUID(context.Context, identity.Requester, string) error
HasUpdateProtected(context.Context, identity.Requester, *models.Receiver) (bool, error)
AuthorizeUpdateProtected(context.Context, identity.Requester, *models.Receiver) error
Access(ctx context.Context, user identity.Requester, receivers ...*models.Receiver) (map[string]models.ReceiverPermissionSet, error)
}
@@ -511,6 +515,18 @@ func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receive
return nil, err
}
// if user does not have permissions to update protected, check the diff and return error if there is a change in protected fields
canUpdateProtected, _ := rs.authz.HasUpdateProtected(ctx, user, r)
if !canUpdateProtected {
diff := models.HasReceiversDifferentProtectedFields(existing, r)
if len(diff) > 0 {
err = rs.authz.AuthorizeUpdateProtected(ctx, user, r)
if err != nil {
return nil, makeProtectedFieldsAuthzError(err, diff)
}
}
}
// We need to perform two important steps to process settings on an updated integration:
// 1. Encrypt new or updated secret fields as they will arrive in plain text.
// 2. For updates, callers do not re-send unchanged secure settings and instead mark them in SecureFields. We need
@@ -805,3 +821,23 @@ func (rs *ReceiverService) RenameReceiverInDependentResources(ctx context.Contex
}
return nil
}
func makeProtectedFieldsAuthzError(err error, diff map[string][]models.IntegrationFieldPath) error {
var authzErr errutil.Error
if !errors.As(err, &authzErr) {
return err
}
if authzErr.PublicPayload == nil {
authzErr.PublicPayload = map[string]interface{}{}
}
fields := make(map[string][]string, len(diff))
for field, paths := range diff {
fields[field] = make([]string, len(paths))
for i, path := range paths {
fields[field][i] = path.String()
}
slices.Sort(fields[field])
}
authzErr.PublicPayload["changed_protected_fields"] = fields
return authzErr
}
@@ -275,7 +275,7 @@ func TestReceiverService_Delete(t *testing.T) {
deleteUID: baseReceiver.UID,
callerProvenance: definitions.Provenance(models.ProvenanceFile),
existing: util.Pointer(models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithProvenance(models.ProvenanceAPI))),
//expectedErr: validation.MakeErrProvenanceChangeNotAllowed(models.ProvenanceAPI, models.ProvenanceFile),
// expectedErr: validation.MakeErrProvenanceChangeNotAllowed(models.ProvenanceAPI, models.ProvenanceFile),
},
{
name: "delete receiver with optimistic version mismatch fails",
@@ -532,8 +532,9 @@ func TestReceiverService_Update(t *testing.T) {
writer := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{
1: {
accesscontrol.ActionAlertingNotificationsWrite: nil,
accesscontrol.ActionAlertingNotificationsRead: nil,
accesscontrol.ActionAlertingNotificationsWrite: nil,
accesscontrol.ActionAlertingNotificationsRead: nil,
accesscontrol.ActionAlertingReceiversUpdateProtected: {ac.ScopeReceiversAll},
},
}}
decryptUser := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{
@@ -673,7 +674,7 @@ func TestReceiverService_Update(t *testing.T) {
user: writer,
receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithProvenance(models.ProvenanceAPI)),
existing: util.Pointer(models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithProvenance(models.ProvenanceFile))),
//expectedErr: validation.MakeErrProvenanceChangeNotAllowed(models.ProvenanceFile, models.ProvenanceAPI),
// expectedErr: validation.MakeErrProvenanceChangeNotAllowed(models.ProvenanceFile, models.ProvenanceAPI),
expectedUpdate: models.CopyReceiverWith(baseReceiver,
models.ReceiverMuts.WithProvenance(models.ProvenanceAPI),
rm.Encrypted(models.Base64Enrypt)),
@@ -1125,7 +1126,7 @@ func TestReceiverServiceAC_Update(t *testing.T) {
},
}}
slackIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("slack"))
slackIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("webhook"))
emailIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("email"))
recv1 := models.ReceiverGen(models.ReceiverMuts.WithName("receiver1"), models.ReceiverMuts.WithIntegrations(slackIntegration(), emailIntegration()))()
recv2 := models.ReceiverGen(models.ReceiverMuts.WithName("receiver2"), models.ReceiverMuts.WithIntegrations(slackIntegration(), emailIntegration()))()
@@ -1137,8 +1138,8 @@ func TestReceiverServiceAC_Update(t *testing.T) {
name string
permissions map[string][]string
existing []models.Receiver
hasAccess []models.Receiver
incoming []models.Receiver
hasAccess []models.Receiver
}{
{
name: "not authorized without permissions",
@@ -1226,6 +1227,43 @@ func TestReceiverServiceAC_Update(t *testing.T) {
existing: allReceivers(),
hasAccess: []models.Receiver{recv1, recv3},
},
{
name: "protected fields modified without permission",
permissions: map[string][]string{
accesscontrol.ActionAlertingReceiversUpdate: {ac.ScopeReceiversAll},
accesscontrol.ActionAlertingReceiversRead: {ac.ScopeReceiversAll},
},
existing: []models.Receiver{
recv1,
},
incoming: []models.Receiver{
func() models.Receiver {
f := recv1.Clone()
f.Integrations[0].Settings["url"] = "https://example.com/new"
return f
}(),
},
hasAccess: nil,
},
{
name: "protected fields modified with permission",
permissions: map[string][]string{
accesscontrol.ActionAlertingReceiversUpdate: {ac.ScopeReceiversAll},
accesscontrol.ActionAlertingReceiversRead: {ac.ScopeReceiversAll},
accesscontrol.ActionAlertingReceiversUpdateProtected: {ac.ScopeReceiversAll},
},
existing: []models.Receiver{
recv1,
},
incoming: []models.Receiver{
func() models.Receiver {
f := recv1.Clone()
f.Integrations[0].Settings["url"] = "https://example.com/new"
return f
}(),
},
hasAccess: []models.Receiver{recv1},
},
}
for _, tc := range testCases {
@@ -1251,7 +1289,11 @@ func TestReceiverServiceAC_Update(t *testing.T) {
}
return false
}
for _, recv := range allReceivers() {
incoming := allReceivers()
if tc.incoming != nil {
incoming = tc.incoming
}
for _, recv := range incoming {
clone := recv.Clone()
clone.Version = versions[recv.UID]
response, err := sut.UpdateReceiver(context.Background(), &clone, nil, orgId, usr)
@@ -116,3 +116,49 @@ func (m *receiverCreateScopeMigration) Exec(sess *xorm.Session, mg *migrator.Mig
func AddReceiverCreateScopeMigration(mg *migrator.Migrator) {
mg.AddMigration("remove scope from alert.notifications.receivers:create", &receiverCreateScopeMigration{})
}
type receiverProtectedFieldsEditor struct {
migrator.MigrationBase
}
var _ migrator.CodeMigration = new(alertingMigrator)
func (m *receiverProtectedFieldsEditor) SQL(migrator.Dialect) string {
return "code migration"
}
func (m *receiverProtectedFieldsEditor) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
sql := `SELECT *
FROM permission AS P
WHERE action = 'alert.notifications.receivers.secrets:read'
AND EXISTS(SELECT 1 FROM role AS R WHERE R.id = P.role_id AND R.name LIKE 'managed:%')
AND NOT EXISTS(SELECT 1
FROM permission AS P2
WHERE P2.role_id = P.role_id
AND P2.action = 'alert.notifications.receivers.protected:write' AND P2.scope = P.scope
)`
var results []accesscontrol.Permission
if err := sess.SQL(sql).Find(&results); err != nil {
return fmt.Errorf("failed to query permissions: %w", err)
}
permissionsToCreate := make([]accesscontrol.Permission, 0, len(results))
rolesAffected := make(map[int64][]string, 0)
for _, result := range results {
result.ID = 0
result.Action = "alert.notifications.receivers.protected:write"
result.Created = time.Now()
result.Updated = time.Now()
permissionsToCreate = append(permissionsToCreate, result)
rolesAffected[result.RoleID] = append(rolesAffected[result.RoleID], result.Identifier)
}
_, err := sess.InsertMulti(&permissionsToCreate)
for id, ids := range rolesAffected {
mg.Logger.Debug("Added permission 'alert.notifications.receivers.protected:write' to managed role", "roleID", id, "identifiers", ids)
}
return err
}
func AddReceiverProtectedFieldsEditor(mg *migrator.Migrator) {
mg.AddMigration("add 'alert.notifications.receivers.protected:write' to receiver admins", &receiverProtectedFieldsEditor{})
}
@@ -153,4 +153,6 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
accesscontrol.AddDatasourceDrilldownRemovalMigration(mg)
ualert.DropTitleUniqueIndexMigration(mg)
accesscontrol.AddReceiverProtectedFieldsEditor(mg)
}
@@ -149,7 +149,7 @@ func TestIntegrationResourcePermissions(t *testing.T) {
adminClient := test_common.NewReceiverClient(t, admin)
writeACMetadata := []string{"canWrite", "canDelete"}
allACMetadata := []string{"canWrite", "canDelete", "canReadSecrets", "canAdmin"}
allACMetadata := []string{"canWrite", "canDelete", "canReadSecrets", "canAdmin", "canModifyProtected"}
mustID := func(user apis.User) int64 {
id, err := user.Identity.GetInternalID()
@@ -412,13 +412,14 @@ func TestIntegrationAccessControl(t *testing.T) {
org1 := helper.Org1
type testCase struct {
user apis.User
canRead bool
canUpdate bool
canCreate bool
canDelete bool
canReadSecrets bool
canAdmin bool
user apis.User
canRead bool
canUpdate bool
canUpdateProtected bool
canCreate bool
canDelete bool
canReadSecrets bool
canAdmin bool
}
// region users
unauthorized := helper.CreateUser("unauthorized", "Org1", org.RoleNone, []resourcepermissions.SetResourcePermissionCommand{})
@@ -481,20 +482,22 @@ func TestIntegrationAccessControl(t *testing.T) {
testCases := []testCase{
{
user: unauthorized,
canRead: false,
canUpdate: false,
canCreate: false,
canDelete: false,
user: unauthorized,
canRead: false,
canUpdate: false,
canUpdateProtected: false,
canCreate: false,
canDelete: false,
},
{
user: org1.Admin,
canRead: true,
canCreate: true,
canUpdate: true,
canDelete: true,
canAdmin: true,
canReadSecrets: true,
user: org1.Admin,
canRead: true,
canCreate: true,
canUpdate: true,
canUpdateProtected: true,
canDelete: true,
canAdmin: true,
canReadSecrets: true,
},
{
user: org1.Editor,
@@ -543,22 +546,24 @@ func TestIntegrationAccessControl(t *testing.T) {
canDelete: true,
},
{
user: adminLikeUser,
canRead: true,
canCreate: true,
canUpdate: true,
canDelete: true,
canAdmin: true,
canReadSecrets: true,
user: adminLikeUser,
canRead: true,
canCreate: true,
canUpdate: true,
canUpdateProtected: true,
canDelete: true,
canAdmin: true,
canReadSecrets: true,
},
{
user: adminLikeUserLongName,
canRead: true,
canCreate: true,
canUpdate: true,
canDelete: true,
canAdmin: true,
canReadSecrets: true,
user: adminLikeUserLongName,
canRead: true,
canCreate: true,
canUpdate: true,
canUpdateProtected: true,
canDelete: true,
canAdmin: true,
canReadSecrets: true,
},
}
@@ -616,6 +621,9 @@ func TestIntegrationAccessControl(t *testing.T) {
if tc.canUpdate {
expectedWithMetadata.SetAccessControl("canWrite")
}
if tc.canUpdateProtected {
expectedWithMetadata.SetAccessControl("canModifyProtected")
}
if tc.canDelete {
expectedWithMetadata.SetAccessControl("canDelete")
}
@@ -679,6 +687,32 @@ func TestIntegrationAccessControl(t *testing.T) {
require.Truef(t, errors.IsNotFound(err), "Should get NotFound error but got: %s", err)
})
})
updatedExpected = expected.Copy().(*v0alpha1.Receiver)
updatedExpected.Spec.Integrations = []v0alpha1.Integration{
createIntegration(t, "webhook"),
}
expected, err = adminClient.Update(ctx, updatedExpected, v1.UpdateOptions{})
require.NoErrorf(t, err, "Payload %s", string(d))
require.NotNil(t, expected)
updatedProtected := expected.Copy().(*v0alpha1.Receiver)
updatedProtected.Spec.Integrations[0].Settings["url"] = "http://localhost:8080/webhook"
if tc.canUpdateProtected {
t.Run("should be able to update protected fields of the receiver", func(t *testing.T) {
updated, err := client.Update(ctx, updatedProtected, v1.UpdateOptions{})
require.NoErrorf(t, err, "Payload %s", string(d))
require.NotNil(t, updated)
expected = updated
})
} else {
t.Run("should be forbidden to edit protected fields of the receiver", func(t *testing.T) {
_, err := client.Update(ctx, updatedProtected, v1.UpdateOptions{})
require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err)
})
}
} else {
t.Run("should be forbidden to update receiver", func(t *testing.T) {
_, err := client.Update(ctx, updatedExpected, v1.UpdateOptions{})
@@ -691,6 +725,7 @@ func TestIntegrationAccessControl(t *testing.T) {
require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err)
})
})
require.Falsef(t, tc.canUpdateProtected, "Invalid combination of assertions. CanUpdateProtected should be false")
}
deleteOptions := v1.DeleteOptions{Preconditions: &v1.Preconditions{ResourceVersion: util.Pointer(expected.ResourceVersion)}}
@@ -1310,6 +1345,7 @@ func TestIntegrationCRUD(t *testing.T) {
receiver.SetAccessControl("canDelete")
receiver.SetAccessControl("canReadSecrets")
receiver.SetAccessControl("canAdmin")
receiver.SetAccessControl("canModifyProtected")
receiver.SetInUse(0, nil)
// Use export endpoint because it's the only way to get decrypted secrets fast.
@@ -44,7 +44,7 @@ beforeEach(() => {
grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite]);
});
const getTemplatePreviewContent = async () => within(screen.getByTestId('template-preview')).getByTestId('mockeditor');
const getTemplatePreviewContent = async () => within(screen.getByTestId('template-preview')).findByTestId('mockeditor');
const templatesSelectorTestId = 'existing-templates-selector';
@@ -1,9 +1,3 @@
/**
* This hook will combine data from both the Alertmanager config
* and (if available) it will also fetch the status from the Grafana Managed status endpoint
*/
import { merge, set } from 'lodash';
import { useMemo } from 'react';
import { receiversApi } from 'app/features/alerting/unified/api/receiversK8sApi';
@@ -13,11 +7,7 @@ import { BaseAlertmanagerArgs, Skippable } from 'app/features/alerting/unified/t
import { cloudNotifierTypes } from 'app/features/alerting/unified/utils/cloud-alertmanager-notifier-types';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { isK8sEntityProvisioned, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
import {
GrafanaManagedContactPoint,
GrafanaManagedReceiverConfig,
Receiver,
} from 'app/plugins/datasource/alertmanager/types';
import { GrafanaManagedContactPoint, Receiver } from 'app/plugins/datasource/alertmanager/types';
import { getAPINamespace } from '../../../../../api/utils';
import { alertmanagerApi } from '../../api/alertmanagerApi';
@@ -327,47 +317,6 @@ export function useDeleteContactPoint({ alertmanager }: BaseAlertmanagerArgs) {
return useK8sApi ? deleteFromK8sAPI : deleteFromAlertmanagerConfiguration;
}
/**
* Turns a Grafana Managed receiver config into a format that can be sent to the k8s API
*
* When updating secure settings, we need to send a value of `true` for any secure setting that we want to keep the same.
*
* Any other setting that has a value in `secureSettings` will correspond to a new value for that setting -
* so we should not tell the API that we want to preserve it. Those values will instead be sent within `settings`
*/
const mapIntegrationSettingsForK8s = (integration: GrafanaManagedReceiverConfig): GrafanaManagedReceiverConfig => {
const { secureSettings, settings, ...restOfIntegration } = integration;
const secureFields = Object.entries(secureSettings || {}).reduce((acc, [key, value]) => {
// If a secure field has no (changed) value, then we tell the backend to persist it
if (value === undefined) {
return {
...acc,
[key]: true,
};
}
return acc;
}, {});
const mappedSecureSettings = Object.entries(secureSettings || {}).reduce((acc, [key, value]) => {
// If the value is an empty string/falsy value, then we need to omit it from the payload
// so the backend knows to remove it
if (!value) {
return acc;
}
// Otherwise, we send the value of the secure field
return set(acc, key, value);
}, {});
// Merge settings properly with lodash so we don't lose any information from nested keys/secure settings
const mergedSettings = merge({}, settings, mappedSecureSettings);
return {
...restOfIntegration,
secureFields,
settings: mergedSettings,
};
};
const grafanaContactPointToK8sReceiver = (
contactPoint: GrafanaManagedContactPoint,
id?: string,
@@ -380,7 +329,7 @@ const grafanaContactPointToK8sReceiver = (
},
spec: {
title: contactPoint.name,
integrations: (contactPoint.grafana_managed_receiver_configs || []).map(mapIntegrationSettingsForK8s),
integrations: contactPoint.grafana_managed_receiver_configs || [],
},
};
};
@@ -87,6 +87,7 @@ export const GlobalConfigForm = ({ config, alertManagerSourceName }: Props) => {
option={option}
error={errors[option.propertyName]}
pathPrefix={''}
secureFields={{}}
/>
))}
<div>
@@ -17,7 +17,7 @@ exports[`new receiver should be able to test and save a receiver 1`] = `
{
"disableResolveMessage": false,
"name": "test",
"secureSettings": {},
"secureFields": {},
"settings": {
"addresses": "tester@grafana.com",
"singleEmail": false,
@@ -2,22 +2,31 @@ import * as React from 'react';
import { DeepMap, FieldError, FieldErrors, useFormContext } from 'react-hook-form';
import { Field, SecretInput } from '@grafana/ui';
import { NotificationChannelOption, NotificationChannelSecureFields } from 'app/types';
import { NotificationChannelOption, NotificationChannelSecureFields, OptionMeta } from 'app/types';
import { ChannelValues, ReceiverFormValues } from '../../../types/receiver-form';
import {
ChannelValues,
CloudChannelValues,
GrafanaChannelValues,
ReceiverFormValues,
} from '../../../types/receiver-form';
import { OptionField } from './fields/OptionField';
export interface Props<R extends ChannelValues> {
defaultValues: R;
selectedChannelOptions: NotificationChannelOption[];
secureFields: NotificationChannelSecureFields;
onResetSecureField: (key: string) => void;
onDeleteSubform?: (settingsPath: string, option: NotificationChannelOption) => void;
errors?: FieldErrors<R>;
pathPrefix?: string;
/**
* The path for the integration in the array of integrations.
* This is used to access the settings and secure fields for the integration in a type-safe way.
*/
integrationPrefix: `items.${number}`;
canEditProtectedFields: boolean;
readOnly?: boolean;
customValidators?: Record<string, React.ComponentProps<typeof OptionField>['customValidator']>;
}
@@ -25,14 +34,24 @@ export function ChannelOptions<R extends ChannelValues>({
defaultValues,
selectedChannelOptions,
onResetSecureField,
secureFields,
onDeleteSubform,
errors,
pathPrefix = '',
integrationPrefix,
readOnly = false,
customValidators = {},
canEditProtectedFields,
}: Props<R>): JSX.Element {
const { watch } = useFormContext<ReceiverFormValues<R>>();
const currentFormValues = watch(); // react hook form types ARE LYING!
const { watch } = useFormContext<ReceiverFormValues<CloudChannelValues | GrafanaChannelValues>>();
const [settings, secureFields] = watch([`${integrationPrefix}.settings`, `${integrationPrefix}.secureFields`]);
// Note: settingsPath includes a trailing dot for OptionField, unlike the path used in watch()
const settingsPath = `${integrationPrefix}.settings.` as const;
const getOptionMeta = (option: NotificationChannelOption): OptionMeta => ({
required: determineRequired(option, settings, secureFields),
readOnly: determineReadOnly(option, settings, secureFields, canEditProtectedFields),
});
return (
<>
@@ -41,43 +60,97 @@ export function ChannelOptions<R extends ChannelValues>({
// Some options can be dependent on other options, this determines what is selected in the dependency options
// I think this needs more thought.
// pathPrefix = items.index.
const paths = pathPrefix.split('.');
const selectedOptionValue =
paths.length >= 2 ? currentFormValues.items?.[Number(paths[1])].settings?.[option.showWhen.field] : undefined;
// const paths = pathPrefix.split('.');
const selectedOptionValue = settings?.[option.showWhen.field];
if (option.showWhen.field && selectedOptionValue !== option.showWhen.is) {
return null;
}
if (secureFields && secureFields[option.propertyName]) {
if (secureFields && secureFields[option.secureFieldKey ?? option.propertyName]) {
return (
<Field key={key} label={option.label} description={option.description}>
<SecretInput onReset={() => onResetSecureField(option.propertyName)} isConfigured />
<Field
key={key}
label={option.label}
description={option.description}
htmlFor={`${settingsPath}${option.propertyName}`}
>
<SecretInput
id={`${settingsPath}${option.propertyName}`}
onReset={() => onResetSecureField(option.secureFieldKey ?? option.propertyName)}
isConfigured
/>
</Field>
);
}
const error: FieldError | DeepMap<any, FieldError> | undefined = (
(option.secure ? errors?.secureSettings : errors?.settings) as DeepMap<any, FieldError> | undefined
)?.[option.propertyName];
const errorSource = option.secure ? errors?.secureFields : errors?.settings;
const propertyKey = option.secureFieldKey ?? option.propertyName;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const error = (
errorSource as Record<string, FieldError | DeepMap<Record<string, unknown>, FieldError>> | undefined
)?.[propertyKey];
const defaultValue = defaultValues?.settings?.[option.propertyName];
return (
<OptionField
onResetSecureField={onResetSecureField}
secureFields={secureFields}
onResetSecureField={onResetSecureField}
onDeleteSubform={onDeleteSubform}
defaultValue={defaultValue}
readOnly={readOnly}
key={key}
error={error}
pathPrefix={pathPrefix}
pathSuffix={option.secure ? 'secureSettings.' : 'settings.'}
pathPrefix={settingsPath}
option={option}
customValidator={customValidators[option.propertyName]}
getOptionMeta={getOptionMeta}
/>
);
})}
</>
);
}
const determineRequired = (
option: NotificationChannelOption,
settings: Record<string, unknown>,
secureFields: NotificationChannelSecureFields
) => {
if (!option.required) {
return false;
}
if (!option.dependsOn) {
return option.required ? 'Required' : false;
}
// TODO: This doesn't work with nested secureFields.
const dependentOn = Boolean(settings[option.dependsOn]) || Boolean(secureFields[option.dependsOn]);
if (dependentOn) {
return false;
}
return 'Required';
};
const determineReadOnly = (
option: NotificationChannelOption,
settings: Record<string, unknown>,
secureFields: NotificationChannelSecureFields,
canEditProtectedFields: boolean
) => {
if (option.protected && !canEditProtectedFields) {
return true;
}
// Handle fields with dependencies (e.g., field B depends on field A being set)
if (!option.dependsOn) {
return false;
}
// TODO: This doesn't work with nested secureFields.
return Boolean(settings[option.dependsOn]) || Boolean(secureFields[option.dependsOn]);
};
@@ -0,0 +1,249 @@
import 'core-js/stable/structured-clone';
import { FormProvider, useForm } from 'react-hook-form';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { render } from 'test/test-utils';
import { byRole, byTestId } from 'testing-library-selector';
import { grafanaAlertNotifiers } from 'app/features/alerting/unified/mockGrafanaNotifiers';
import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { ChannelSubForm } from './ChannelSubForm';
import { GrafanaCommonChannelSettings } from './GrafanaCommonChannelSettings';
import { Notifier } from './notifiers';
type TestChannelValues = {
__id: string;
type: string;
settings: Record<string, unknown>;
secureFields: Record<string, boolean>;
};
type TestReceiverFormValues = {
name: string;
items: TestChannelValues[];
};
const ui = {
typeSelector: byTestId('items.0.type'),
settings: {
webhook: {
url: byRole('textbox', { name: /^URL/ }),
optionalSettings: byRole('button', { name: /optional webhook settings/i }),
title: {
container: byTestId('items.0.settings.title'),
input: byRole('textbox', { name: /^Title/ }),
},
message: {
container: byTestId('items.0.settings.message'),
input: byRole('textbox', { name: /^Message/ }),
},
},
slack: {
recipient: byTestId('items.0.settings.recipient'),
token: byTestId('items.0.settings.token'),
username: byTestId('items.0.settings.username'),
webhookUrl: byRole('textbox', { name: /^Webhook URL/ }),
},
googlechat: {
optionalSettings: byRole('button', { name: /optional google hangouts chat settings/i }),
url: byRole('textbox', { name: /^URL/ }),
title: {
input: byRole('textbox', { name: /^Title/ }),
container: byTestId('items.0.settings.title'),
},
message: {
input: byRole('textbox', { name: /^Message/ }),
container: byTestId('items.0.settings.message'),
},
},
},
};
const notifiers: Notifier[] = [
{ dto: grafanaAlertNotifiers.webhook, meta: { enabled: true, order: 1 } },
{ dto: grafanaAlertNotifiers.slack, meta: { enabled: true, order: 2 } },
{ dto: grafanaAlertNotifiers.googlechat, meta: { enabled: true, order: 3 } },
{ dto: grafanaAlertNotifiers.sns, meta: { enabled: true, order: 4 } },
{ dto: grafanaAlertNotifiers.oncall, meta: { enabled: true, order: 5 } },
];
describe('ChannelSubForm', () => {
function TestFormWrapper({ defaults, initial }: { defaults: TestChannelValues; initial?: TestChannelValues }) {
const form = useForm<TestReceiverFormValues>({
defaultValues: {
name: 'test-contact-point',
items: [defaults],
},
});
return (
<AlertmanagerProvider accessType="notification">
<FormProvider {...form}>
<ChannelSubForm
defaultValues={{ ...defaults, secureSettings: {} }}
initialValues={initial ? { ...initial, secureSettings: {} } : undefined}
pathPrefix={`items.0.`}
integrationIndex={0}
notifiers={notifiers}
onDuplicate={jest.fn()}
commonSettingsComponent={GrafanaCommonChannelSettings}
isEditable={true}
isTestable={false}
canEditProtectedFields={true}
/>
</FormProvider>
</AlertmanagerProvider>
);
}
function renderForm(defaults: TestChannelValues, initial?: TestChannelValues) {
return render(<TestFormWrapper defaults={defaults} initial={initial} />);
}
it('switching type hides prior fields and shows new ones', async () => {
renderForm({
__id: 'id-0',
type: 'webhook',
settings: { url: '' },
secureFields: {},
});
expect(ui.typeSelector.get()).toHaveTextContent('Webhook');
expect(ui.settings.webhook.url.get()).toBeInTheDocument();
expect(ui.settings.slack.recipient.query()).not.toBeInTheDocument();
await clickSelectOption(ui.typeSelector.get(), 'Slack');
expect(ui.typeSelector.get()).toHaveTextContent('Slack');
expect(ui.settings.slack.recipient.get()).toBeInTheDocument();
expect(ui.settings.slack.token.get()).toBeInTheDocument();
expect(ui.settings.slack.username.get()).toBeInTheDocument();
});
it('should clear secure fields when switching integration types', async () => {
const googlechatDefaults: TestChannelValues = {
__id: 'id-0',
type: 'googlechat',
settings: { title: 'Alert Title', message: 'Alert Message' },
secureFields: { url: true },
};
const { user } = renderForm(googlechatDefaults, googlechatDefaults);
expect(ui.typeSelector.get()).toHaveTextContent('Google Hangouts Chat');
expect(ui.settings.googlechat.url.get()).toBeDisabled();
expect(ui.settings.googlechat.url.get()).toHaveValue('configured');
await user.click(ui.settings.googlechat.optionalSettings.get());
expect(ui.settings.googlechat.title.input.get()).toHaveValue('Alert Title');
expect(ui.settings.googlechat.message.input.get()).toHaveValue('Alert Message');
await clickSelectOption(ui.typeSelector.get(), 'Webhook');
expect(ui.typeSelector.get()).toHaveTextContent('Webhook');
// Webhook URL field should now be present and empty (settings cleared)
expect(ui.settings.webhook.url.get()).toHaveValue('');
expect(ui.settings.webhook.title.container.get()).toBeInTheDocument();
expect(ui.settings.webhook.message.container.get()).toBeInTheDocument();
// If value for templated fields is empty the input should not be present
expect(ui.settings.webhook.message.input.query()).not.toBeInTheDocument();
expect(ui.settings.webhook.title.input.query()).not.toBeInTheDocument();
});
it('should clear settings when switching from webhook to googlechat', async () => {
const webhookDefaults: TestChannelValues = {
__id: 'id-0',
type: 'webhook',
settings: { url: 'https://example.com/webhook', title: 'Webhook Title', message: 'Webhook Message' },
secureFields: {},
};
const { user } = renderForm(webhookDefaults, webhookDefaults);
expect(ui.typeSelector.get()).toHaveTextContent('Webhook');
expect(ui.settings.webhook.url.get()).toHaveValue('https://example.com/webhook');
await user.click(ui.settings.webhook.optionalSettings.get());
expect(ui.settings.webhook.title.input.get()).toHaveValue('Webhook Title');
expect(ui.settings.webhook.message.input.get()).toHaveValue('Webhook Message');
await clickSelectOption(ui.typeSelector.get(), 'Google Hangouts Chat');
expect(ui.typeSelector.get()).toHaveTextContent('Google Hangouts Chat');
// Google Chat URL field should now be present and empty (settings cleared)
expect(ui.settings.googlechat.url.get()).toHaveValue('');
expect(ui.settings.googlechat.title.container.get()).toBeInTheDocument();
expect(ui.settings.googlechat.message.container.get()).toBeInTheDocument();
// If value for templated fields is empty the input should not be present
expect(ui.settings.googlechat.message.input.query()).not.toBeInTheDocument();
expect(ui.settings.googlechat.title.input.query()).not.toBeInTheDocument();
});
it('should restore initial values when switching back to original type', async () => {
const googlechatDefaults: TestChannelValues = {
__id: 'id-0',
type: 'googlechat',
settings: { title: 'Original Title', message: 'Original Message' },
secureFields: { url: true },
};
const { user } = renderForm(googlechatDefaults, googlechatDefaults);
expect(ui.typeSelector.get()).toHaveTextContent('Google Hangouts Chat');
expect(ui.settings.googlechat.url.get()).toBeDisabled();
expect(ui.settings.googlechat.url.get()).toHaveValue('configured');
await user.click(ui.settings.googlechat.optionalSettings.get());
expect(ui.settings.googlechat.title.input.get()).toHaveValue('Original Title');
expect(ui.settings.googlechat.message.input.get()).toHaveValue('Original Message');
// Switch to a different type
await clickSelectOption(ui.typeSelector.get(), 'Webhook');
expect(ui.typeSelector.get()).toHaveTextContent('Webhook');
expect(ui.settings.webhook.url.get()).toHaveValue('');
// Switch back to the original type
await clickSelectOption(ui.typeSelector.get(), 'Google Hangouts Chat');
expect(ui.typeSelector.get()).toHaveTextContent('Google Hangouts Chat');
// Original settings and secure fields should be restored
expect(ui.settings.googlechat.url.get()).toBeDisabled();
expect(ui.settings.googlechat.url.get()).toHaveValue('configured');
expect(ui.settings.googlechat.title.input.get()).toHaveValue('Original Title');
expect(ui.settings.googlechat.message.input.get()).toHaveValue('Original Message');
});
it('should maintain secure field isolation across multiple type switches', async () => {
const googlechatDefaults: TestChannelValues = {
__id: 'id-0',
type: 'googlechat',
settings: {},
secureFields: { url: true },
};
renderForm(googlechatDefaults, googlechatDefaults);
expect(ui.typeSelector.get()).toHaveTextContent('Google Hangouts Chat');
expect(ui.settings.googlechat.url.get()).toBeDisabled();
expect(ui.settings.googlechat.url.get()).toHaveValue('configured');
// Switch to Slack
await clickSelectOption(ui.typeSelector.get(), 'Slack');
expect(ui.typeSelector.get()).toHaveTextContent('Slack');
// Slack should not have any secure fields from Google Chat
const slackUrl = ui.settings.slack.webhookUrl.get();
expect(slackUrl).toBeEnabled();
expect(slackUrl).toHaveValue('');
});
});
@@ -1,35 +1,41 @@
import { css } from '@emotion/css';
import { sortBy } from 'lodash';
import * as React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Controller, FieldErrors, FieldValues, useFormContext } from 'react-hook-form';
import { useEffect, useMemo } from 'react';
import { Controller, FieldErrors, useFormContext } from 'react-hook-form';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Alert, Button, Field, Select, Stack, Text, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { NotificationChannelOption } from 'app/types';
import { useUnifiedAlertingSelector } from '../../../hooks/useUnifiedAlertingSelector';
import { ChannelValues, CommonSettingsComponentType } from '../../../types/receiver-form';
import {
ChannelValues,
CloudChannelValues,
CommonSettingsComponentType,
GrafanaChannelValues,
ReceiverFormValues,
} from '../../../types/receiver-form';
import { OnCallIntegrationType } from '../grafanaAppReceivers/onCall/useOnCallIntegration';
import { ChannelOptions } from './ChannelOptions';
import { CollapsibleSection } from './CollapsibleSection';
import { Notifier } from './notifiers';
interface Props<R extends FieldValues> {
interface Props<R extends ChannelValues> {
defaultValues: R;
initialValues?: R;
pathPrefix: string;
pathPrefix: `items.${number}.`;
integrationIndex: number;
notifiers: Notifier[];
onDuplicate: () => void;
onTest?: () => void;
commonSettingsComponent: CommonSettingsComponentType;
secureFields?: Record<string, boolean>;
errors?: FieldErrors<R>;
onDelete?: () => void;
isEditable?: boolean;
isTestable?: boolean;
canEditProtectedFields: boolean;
customValidators?: React.ComponentProps<typeof ChannelOptions>['customValidators'];
}
@@ -38,74 +44,130 @@ export function ChannelSubForm<R extends ChannelValues>({
defaultValues,
initialValues,
pathPrefix,
integrationIndex,
onDuplicate,
onDelete,
onTest,
notifiers,
errors,
secureFields,
commonSettingsComponent: CommonSettingsComponent,
isEditable = true,
isTestable,
canEditProtectedFields,
customValidators = {},
}: Props<R>): JSX.Element {
const styles = useStyles2(getStyles);
const { control, watch, register, trigger, formState, setValue, getValues } =
useFormContext<ReceiverFormValues<CloudChannelValues | GrafanaChannelValues>>();
const fieldName = useCallback((fieldName: string) => `${pathPrefix}${fieldName}`, [pathPrefix]);
const channelFieldPath = `items.${integrationIndex}` as const;
const typeFieldPath = `${channelFieldPath}.type` as const;
const settingsFieldPath = `${channelFieldPath}.settings` as const;
const secureFieldsPath = `${channelFieldPath}.secureFields` as const;
const { control, watch, register, trigger, formState, setValue } = useFormContext();
const selectedType = watch(fieldName('type')) ?? defaultValues.type; // nope, setting "default" does not work at all.
const parse_mode = watch(fieldName('settings.parse_mode'));
const { loading: testingReceiver } = useUnifiedAlertingSelector((state) => state.testReceivers);
const selectedType = watch(typeFieldPath) ?? defaultValues.type;
const parse_mode = watch(`${settingsFieldPath}.parse_mode`);
// TODO I don't like integration specific code here but other ways require a bigger refactoring
const onCallIntegrationType = watch(fieldName('settings.integration_type'));
const onCallIntegrationType = watch(`${settingsFieldPath}.integration_type`);
const isTestAvailable = onCallIntegrationType !== OnCallIntegrationType.NewIntegration;
useEffect(() => {
register(`${pathPrefix}.__id`);
register(`${channelFieldPath}.__id`);
/* Need to manually register secureFields or else they'll
be lost when testing a contact point */
register(`${pathPrefix}.secureFields`);
}, [register, pathPrefix]);
register(`${channelFieldPath}.secureFields`);
}, [register, channelFieldPath]);
// Prevent forgetting about initial values when switching the integration type and the oncall integration type
useEffect(() => {
// Restore values when switching back from a changed integration to the default one
const subscription = watch((v, { name, type }) => {
const value = name ? v[name] : '';
if (initialValues && name === fieldName('type') && value === initialValues.type && type === 'change') {
setValue(fieldName('settings'), initialValues.settings);
const subscription = watch((formValues, { name, type }) => {
// @ts-expect-error name is valid key for formValues
const value = name ? getValues(name, formValues) : '';
if (initialValues && name === typeFieldPath && value === initialValues.type && type === 'change') {
setValue(settingsFieldPath, initialValues.settings);
setValue(secureFieldsPath, initialValues.secureFields);
} else if (name === typeFieldPath && type === 'change') {
// When switching to a new notifier, set the default settings to remove all existing settings
// from the previous notifier
const newNotifier = notifiers.find(({ dto: { type } }) => type === value);
const defaultNotifierSettings = newNotifier ? getDefaultNotifierSettings(newNotifier) : {};
// Not sure why, but verriding settingsFieldPath is not enough if notifiers have the same settings fields, like url, title
const currentSettings = getValues(settingsFieldPath) ?? {};
Object.keys(currentSettings).forEach((key) => {
if (!defaultNotifierSettings[key]) {
setValue(`${settingsFieldPath}.${key}`, defaultNotifierSettings[key]);
}
});
setValue(settingsFieldPath, defaultNotifierSettings);
setValue(secureFieldsPath, {});
}
// Restore initial value of an existing oncall integration
if (
initialValues &&
name === fieldName('settings.integration_type') &&
name === `${settingsFieldPath}.integration_type` &&
value === OnCallIntegrationType.ExistingIntegration
) {
setValue(fieldName('settings.url'), initialValues.settings.url);
setValue(`${settingsFieldPath}.url`, initialValues.settings.url);
}
});
return () => subscription.unsubscribe();
}, [selectedType, initialValues, setValue, fieldName, watch]);
const [_secureFields, setSecureFields] = useState<Record<string, boolean | ''>>(secureFields ?? {});
}, [
selectedType,
initialValues,
setValue,
settingsFieldPath,
typeFieldPath,
secureFieldsPath,
getValues,
watch,
defaultValues.settings,
defaultValues.secureFields,
notifiers,
]);
const onResetSecureField = (key: string) => {
if (_secureFields[key]) {
const updatedSecureFields = { ..._secureFields };
updatedSecureFields[key] = '';
setSecureFields(updatedSecureFields);
setValue(`${pathPrefix}.secureFields`, updatedSecureFields);
// formSecureFields might not be up to date if this function is called multiple times in a row
const currentSecureFields = getValues(`${channelFieldPath}.secureFields`);
if (currentSecureFields[key]) {
setValue(`${channelFieldPath}.secureFields`, { ...currentSecureFields, [key]: '' });
}
};
const findSecureFieldsRecursively = (options: NotificationChannelOption[]): string[] => {
const secureFields: string[] = [];
options?.forEach((option) => {
if (option.secure && option.secureFieldKey) {
secureFields.push(option.secureFieldKey);
}
if (option.subformOptions) {
secureFields.push(...findSecureFieldsRecursively(option.subformOptions));
}
});
return secureFields;
};
const onDeleteSubform = (settingsPath: string, option: NotificationChannelOption) => {
// Get all subform options with secure=true recursively.
const relatedSecureFields = findSecureFieldsRecursively(option.subformOptions ?? []);
relatedSecureFields.forEach((key) => {
onResetSecureField(key);
});
const fieldPath = settingsPath.startsWith(`${channelFieldPath}.settings.`)
? settingsPath.slice(`${channelFieldPath}.settings.`.length)
: settingsPath;
setValue(`${settingsFieldPath}.${fieldPath}`, undefined);
};
const typeOptions = useMemo(
(): SelectableValue[] =>
sortBy(notifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name])
// .notifiers.sort((a, b) => a.dto.name.localeCompare(b.dto.name))
.map<SelectableValue>(({ dto: { name, type }, meta }) => ({
sortBy(notifiers, ({ dto, meta }) => [meta?.order ?? 0, dto.name]).map<SelectableValue>(
({ dto: { name, type }, meta }) => ({
// @ts-expect-error ReactNode is supported
label: (
<Stack alignItems="center" gap={1}>
@@ -116,7 +178,8 @@ export function ChannelSubForm<R extends ChannelValues>({
value: type,
description: meta?.description,
isDisabled: meta ? !meta.enabled : false,
})),
})
),
[notifiers]
);
@@ -137,8 +200,8 @@ export function ChannelSubForm<R extends ChannelValues>({
const showTelegramWarning = isTelegram && !isParseModeNone;
// if there are mandatory options defined, optional options will be hidden by a collapse
// if there aren't mandatory options, all options will be shown without collapse
const mandatoryOptions = notifier?.dto.options.filter((o) => o.required);
const optionalOptions = notifier?.dto.options.filter((o) => !o.required);
const mandatoryOptions = notifier?.dto.options.filter((o) => o.required) ?? [];
const optionalOptions = notifier?.dto.options.filter((o) => !o.required) ?? [];
const contactPointTypeInputId = `contact-point-type-${pathPrefix}`;
return (
@@ -151,7 +214,8 @@ export function ChannelSubForm<R extends ChannelValues>({
data-testid={`${pathPrefix}type`}
>
<Controller
name={fieldName('type')}
name={typeFieldPath}
control={control}
defaultValue={defaultValues.type}
render={({ field: { ref, onChange, ...field } }) => (
<Select
@@ -163,21 +227,12 @@ export function ChannelSubForm<R extends ChannelValues>({
onChange={(value) => onChange(value?.value)}
/>
)}
control={control}
rules={{ required: true }}
/>
</Field>
</div>
<div className={styles.buttons}>
{isTestable && onTest && isTestAvailable && (
<Button
disabled={testingReceiver}
size="xs"
variant="secondary"
type="button"
onClick={() => handleTest()}
icon={testingReceiver ? 'spinner' : 'message'}
>
<Button size="xs" variant="secondary" type="button" onClick={() => handleTest()} icon="message">
<Trans i18nKey="alerting.channel-sub-form.test">Test</Trans>
</Button>
)}
@@ -221,16 +276,21 @@ export function ChannelSubForm<R extends ChannelValues>({
)}
<ChannelOptions<R>
defaultValues={defaultValues}
selectedChannelOptions={mandatoryOptions?.length ? mandatoryOptions! : optionalOptions!}
secureFields={_secureFields}
selectedChannelOptions={mandatoryOptions.length ? mandatoryOptions : optionalOptions}
errors={errors}
onResetSecureField={onResetSecureField}
pathPrefix={pathPrefix}
onDeleteSubform={onDeleteSubform}
integrationPrefix={channelFieldPath}
readOnly={!isEditable}
canEditProtectedFields={canEditProtectedFields}
customValidators={customValidators}
/>
{!!(mandatoryOptions?.length && optionalOptions?.length) && (
<CollapsibleSection label={`Optional ${notifier.dto.name} settings`}>
{!!(mandatoryOptions.length && optionalOptions.length) && (
<CollapsibleSection
label={t('alerting.channel-sub-form.label-section', 'Optional {{name}} settings', {
name: notifier.dto.name,
})}
>
{notifier.dto.info !== '' && (
<Alert title="" severity="info">
{notifier.dto.info}
@@ -238,12 +298,13 @@ export function ChannelSubForm<R extends ChannelValues>({
)}
<ChannelOptions<R>
defaultValues={defaultValues}
selectedChannelOptions={optionalOptions!}
secureFields={_secureFields}
selectedChannelOptions={optionalOptions}
onResetSecureField={onResetSecureField}
onDeleteSubform={onDeleteSubform}
errors={errors}
pathPrefix={pathPrefix}
integrationPrefix={channelFieldPath}
readOnly={!isEditable}
canEditProtectedFields={canEditProtectedFields}
customValidators={customValidators}
/>
</CollapsibleSection>
@@ -259,6 +320,16 @@ export function ChannelSubForm<R extends ChannelValues>({
);
}
function getDefaultNotifierSettings(notifier: Notifier): Record<string, string> {
const defaultSettings: Record<string, string> = {};
notifier.dto.options.forEach((option) => {
if (option.defaultValue?.value) {
defaultSettings[option.propertyName] = option.defaultValue?.value;
}
});
return defaultSettings;
}
const getStyles = (theme: GrafanaTheme2) => ({
buttons: css({
'& > * + *': {
@@ -89,6 +89,7 @@ export const CloudReceiverForm = ({ contactPoint, alertManagerSourceName, readOn
alertManagerSourceName={alertManagerSourceName}
defaultItem={defaultChannelValues}
commonSettingsComponent={CloudCommonChannelSettings}
canEditProtectedFields={true}
/>
</>
);
@@ -3,19 +3,20 @@ import { useMemo, useState } from 'react';
import { locationService } from '@grafana/runtime';
import { Alert, LoadingPlaceholder } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv';
import {
useCreateContactPoint,
useUpdateContactPoint,
} from 'app/features/alerting/unified/components/contact-points/useContactPoints';
import { showManageContactPointPermissions } from 'app/features/alerting/unified/components/contact-points/utils';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { canEditEntity } from 'app/features/alerting/unified/utils/k8s/utils';
import { canEditEntity, canModifyProtectedEntity } from 'app/features/alerting/unified/utils/k8s/utils';
import {
GrafanaManagedContactPoint,
GrafanaManagedReceiverConfig,
TestReceiversAlert,
} from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
import { AccessControlAction, useDispatch } from 'app/types';
import { alertmanagerApi } from '../../../api/alertmanagerApi';
import { testReceiversAction } from '../../../state/actions';
@@ -84,11 +85,11 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
return [undefined, {}];
}
return grafanaReceiverToFormValues(extendOnCallReceivers(contactPoint), grafanaNotifiers);
}, [contactPoint, isLoadingNotifiers, grafanaNotifiers, extendOnCallReceivers, isLoadingOnCallIntegration]);
return grafanaReceiverToFormValues(extendOnCallReceivers(contactPoint));
}, [contactPoint, isLoadingNotifiers, extendOnCallReceivers, isLoadingOnCallIntegration]);
const onSubmit = async (values: ReceiverFormValues<GrafanaChannelValues>) => {
const newReceiver = formValuesToGrafanaReceiver(values, id2original, defaultChannelValues, grafanaNotifiers);
const newReceiver = formValuesToGrafanaReceiver(values, id2original, defaultChannelValues);
try {
if (editMode) {
@@ -136,11 +137,16 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
dispatch(testReceiversAction(payload));
}
};
const isEditable = Boolean(
(!readOnly || (contactPoint && canEditEntity(contactPoint))) && !contactPoint?.provisioned
const hasGlobalEditProtectedPermission = contextSrv.hasPermission(
AccessControlAction.AlertingReceiversUpdateProtected
);
// If there is no contact point it means we're creating a new one, so scoped permissions doesn't exist yet
const hasScopedEditPermissions = contactPoint ? canEditEntity(contactPoint) : true;
const hasScopedEditProtectedPermissions = contactPoint ? canModifyProtectedEntity(contactPoint) : true;
const isEditable = !readOnly && hasScopedEditPermissions && !contactPoint?.provisioned;
const isTestable = !readOnly;
// If we're using k8s API, we need to check the scoped permissions, otherwise we need to check the global permission
const canEditProtectedField = useK8sAPI ? hasScopedEditProtectedPermissions : hasGlobalEditProtectedPermission;
if (isLoadingNotifiers || isLoadingOnCallIntegration) {
return (
@@ -190,6 +196,7 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
canManagePermissions={
editMode && contactPoint && showManageContactPointPermissions(GRAFANA_RULES_SOURCE_NAME, contactPoint)
}
canEditProtectedFields={canEditProtectedField}
/>
<TestContactPointModal
onDismiss={() => setTestChannelValues(undefined)}
@@ -42,6 +42,7 @@ interface Props<R extends ChannelValues> {
showDefaultRouteWarning?: boolean;
contactPointId?: string;
canManagePermissions?: boolean;
canEditProtectedFields: boolean;
}
export function ReceiverForm<R extends ChannelValues>({
@@ -58,6 +59,7 @@ export function ReceiverForm<R extends ChannelValues>({
showDefaultRouteWarning,
contactPointId,
canManagePermissions,
canEditProtectedFields,
}: Props<R>) {
const notifyApp = useAppNotification();
const styles = useStyles2(getStyles);
@@ -66,15 +68,16 @@ export function ReceiverForm<R extends ChannelValues>({
// normalize deprecated and new config values
const normalizedConfig = normalizeFormValues(initialValues);
const defaultValues = normalizedConfig ?? {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const defaultValues = (normalizedConfig ?? {
name: '',
items: [
{
...defaultItem,
__id: String(Math.random()),
} as any,
},
],
};
}) as ReceiverFormValues<R>;
const formAPI = useForm<ReceiverFormValues<R>>({
// making a copy here beacuse react-hook-form will mutate these, and break if the object is frozen. for real.
@@ -93,17 +96,6 @@ 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,
@@ -129,14 +121,18 @@ export function ReceiverForm<R extends ChannelValues>({
<FormProvider {...formAPI}>
{showDefaultRouteWarning && (
<Alert severity="warning" title={t('alerting.receiver-form.title-attention', 'Attention')}>
Because there is no default policy configured yet, this contact point will automatically be set as default.
<Trans i18nKey="alerting.receiver-form.body-attention">
Because there is no default policy configured yet, this contact point will automatically be set as default.
</Trans>
</Alert>
)}
<form onSubmit={handleSubmit(submitCallback, onInvalid)} className={styles.wrapper}>
<Stack justifyContent="space-between" alignItems="center">
<h2 className={styles.heading}>
{!isEditable ? 'Contact point' : initialValues ? 'Update contact point' : 'Create contact point'}
{!isEditable && t('alerting.receiver-form.contact-point', 'Contact point')}
{isEditable && initialValues && t('alerting.receiver-form.contact-point-update', 'Update contact point')}
{isEditable && !initialValues && t('alerting.receiver-form.contact-point-create', 'Create contact point')}
</h2>
{canManagePermissions && contactPointId && (
<ManagePermissions
@@ -171,7 +167,7 @@ export function ReceiverForm<R extends ChannelValues>({
/>
</Field>
{fields.map((field, index) => {
const pathPrefix = `items.${index}.`;
const pathPrefix = `items.${index}.` as const;
if (field.__deleted) {
return <DeletedSubForm key={field.__id} pathPrefix={pathPrefix} />;
}
@@ -181,6 +177,7 @@ export function ReceiverForm<R extends ChannelValues>({
defaultValues={field}
initialValues={initialItem}
key={field.__id}
integrationIndex={index}
onDuplicate={() => {
const currentValues: R = getValues().items[index];
append({ ...currentValues, __id: String(Math.random()) });
@@ -196,11 +193,12 @@ export function ReceiverForm<R extends ChannelValues>({
onDelete={() => remove(index)}
pathPrefix={pathPrefix}
notifiers={notifiers}
secureFields={initialItem?.secureFields}
errors={errors?.items?.[index] as FieldErrors<R>}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
errors={errors?.items?.[index] as FieldErrors<R> | undefined}
commonSettingsComponent={commonSettingsComponent}
isEditable={isEditable}
isTestable={isTestable}
canEditProtectedFields={canEditProtectedFields}
customValidators={customValidators ? customValidators[field.type] : undefined}
/>
);
@@ -8,11 +8,7 @@ exports[`GrafanaReceiverForm handles nested secure fields correctly 1`] = `
{
"disableResolveMessage": false,
"name": "mqtt contact point",
"secureFields": {
"password": true,
"tlsConfig.clientCertificate": true,
"tlsConfig.clientKey": true,
},
"secureFields": {},
"settings": {
"brokerUrl": "broker url",
"retain": false,
@@ -0,0 +1,444 @@
import userEvent from '@testing-library/user-event';
import { FormProvider, useForm } from 'react-hook-form';
import { render, screen, waitFor } from 'test/test-utils';
import { NotificationChannelOption, NotificationChannelSecureFields, OptionMeta } from 'app/types';
import { OptionField } from './OptionField';
const TestWrapper = ({ children }: { children: React.ReactNode }) => {
const methods = useForm();
return <FormProvider {...methods}>{children}</FormProvider>;
};
const renderOptionField = (
option: NotificationChannelOption,
props: {
getOptionMeta?: (option: NotificationChannelOption) => OptionMeta;
readOnly?: boolean;
secureFields?: NotificationChannelSecureFields;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
defaultValue?: any;
} = {}
) => {
const defaultProps = {
option,
defaultValue: '',
pathPrefix: 'test.',
secureFields: {},
...props,
};
return render(
<TestWrapper>
<OptionField {...defaultProps} />
</TestWrapper>
);
};
describe('OptionField', () => {
describe('Protected field indicator', () => {
it('should display lock icon with tooltip when field is protected and readOnly', async () => {
const option: NotificationChannelOption = {
propertyName: 'testField',
label: 'Test Field',
description: 'A test field',
element: 'input',
inputType: 'text',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: true,
dependsOn: '',
};
const getOptionMeta = jest.fn().mockReturnValue({ readOnly: true, required: false });
renderOptionField(option, { getOptionMeta });
// Check that lock icon is displayed
const lockIcon = screen.getByTestId('lock-icon');
expect(lockIcon).toBeInTheDocument();
// Hover over the icon to show tooltip
await userEvent.hover(lockIcon);
// Check that tooltip appears with correct text
await waitFor(() => {
expect(
screen.getByText('This field is protected and can only be edited by users with elevated permissions')
).toBeInTheDocument();
});
});
it('should NOT display lock icon when field is protected but NOT readOnly', () => {
const option: NotificationChannelOption = {
propertyName: 'testField',
label: 'Test Field',
description: 'A test field',
element: 'input',
inputType: 'text',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: true,
dependsOn: '',
};
const getOptionMeta = jest.fn().mockReturnValue({ readOnly: false, required: false });
renderOptionField(option, { getOptionMeta });
// Lock icon should not be displayed
expect(screen.queryByTestId('lock-icon')).not.toBeInTheDocument();
});
it('should NOT display lock icon when field is NOT protected', () => {
const option: NotificationChannelOption = {
propertyName: 'testField',
label: 'Test Field',
description: 'A test field',
element: 'input',
inputType: 'text',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: false,
dependsOn: '',
};
const getOptionMeta = jest.fn().mockReturnValue({ readOnly: true, required: false });
renderOptionField(option, { getOptionMeta });
// Lock icon should not be displayed
expect(screen.queryByTestId('lock-icon')).not.toBeInTheDocument();
});
it('should NOT display lock icon when getOptionMeta is not provided', () => {
const option: NotificationChannelOption = {
propertyName: 'testField',
label: 'Test Field',
description: 'A test field',
element: 'input',
inputType: 'text',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: true,
dependsOn: '',
};
renderOptionField(option);
// Lock icon should not be displayed
expect(screen.queryByTestId('lock-icon')).not.toBeInTheDocument();
});
it('should display lock icon for checkbox fields when protected and readOnly', () => {
const option: NotificationChannelOption = {
propertyName: 'testCheckbox',
label: 'Test Checkbox',
description: 'A test checkbox',
element: 'checkbox',
inputType: '',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: true,
dependsOn: '',
};
const getOptionMeta = jest.fn().mockReturnValue({ readOnly: true, required: false });
renderOptionField(option, { getOptionMeta });
// Lock icon should be displayed even for checkbox
const lockIcon = screen.getByTestId('lock-icon');
expect(lockIcon).toBeInTheDocument();
});
it('should display lock icon for select fields when protected and readOnly', () => {
const option: NotificationChannelOption = {
propertyName: 'testSelect',
label: 'Test Select',
description: 'A test select',
element: 'select',
inputType: '',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: true,
dependsOn: '',
selectOptions: [
{ label: 'Option 1', value: 'opt1' },
{ label: 'Option 2', value: 'opt2' },
],
};
const getOptionMeta = jest.fn().mockReturnValue({ readOnly: true, required: false });
renderOptionField(option, { getOptionMeta });
// Lock icon should be displayed
const lockIcon = screen.getByTestId('lock-icon');
expect(lockIcon).toBeInTheDocument();
});
});
describe('Subform fields', () => {
it('should pass getOptionMeta to SubformField component', () => {
const getOptionMeta = jest.fn().mockReturnValue({ readOnly: true, required: false });
const option: NotificationChannelOption = {
propertyName: 'testSubform',
label: 'Test Subform',
description: 'A test subform',
element: 'subform',
inputType: '',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: false,
dependsOn: '',
subformOptions: [
{
propertyName: 'nestedField',
label: 'Nested Field',
description: 'A nested field',
element: 'input',
inputType: 'text',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: true,
dependsOn: '',
},
],
};
renderOptionField(option, { getOptionMeta, defaultValue: { nestedField: 'test' } });
// The subform should be rendered with the nested field
expect(screen.getByText('Test Subform')).toBeInTheDocument();
// Verify that getOptionMeta was called for the nested field
// This ensures it was passed through to the SubformField component
expect(getOptionMeta).toHaveBeenCalled();
});
it('should display lock icon for protected fields inside subform when readOnly', async () => {
const getOptionMeta = jest.fn((opt) => {
// Make the nested protected field readOnly
if (opt.protected) {
return { readOnly: true, required: false };
}
return { readOnly: false, required: false };
});
const option: NotificationChannelOption = {
propertyName: 'oauth2',
label: 'OAuth2 Configuration',
description: 'OAuth2 settings',
element: 'subform',
inputType: '',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: false,
dependsOn: '',
subformOptions: [
{
propertyName: 'token_url',
label: 'Token URL',
description: 'OAuth2 token URL',
element: 'input',
inputType: 'text',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: true,
dependsOn: '',
},
],
};
renderOptionField(option, { getOptionMeta, defaultValue: { token_url: 'https://example.com/token' } });
// Check that lock icon is displayed for the nested protected field
const lockIcon = screen.getByTestId('lock-icon');
expect(lockIcon).toBeInTheDocument();
// Hover over the icon to show tooltip
await userEvent.hover(lockIcon);
// Check that tooltip appears
await waitFor(() => {
expect(
screen.getByText('This field is protected and can only be edited by users with elevated permissions')
).toBeInTheDocument();
});
});
it('should NOT display lock icon for protected fields inside subform when user can edit', () => {
const getOptionMeta = jest.fn().mockReturnValue({ readOnly: false, required: false });
const option: NotificationChannelOption = {
propertyName: 'oauth2',
label: 'OAuth2 Configuration',
description: 'OAuth2 settings',
element: 'subform',
inputType: '',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: false,
dependsOn: '',
subformOptions: [
{
propertyName: 'token_url',
label: 'Token URL',
description: 'OAuth2 token URL',
element: 'input',
inputType: 'text',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: true,
dependsOn: '',
},
],
};
renderOptionField(option, { getOptionMeta, defaultValue: { token_url: 'https://example.com/token' } });
// Lock icon should not be displayed when user has permission
expect(screen.queryByTestId('lock-icon')).not.toBeInTheDocument();
});
});
describe('Subform array fields', () => {
it('should pass getOptionMeta to SubformArrayField component', () => {
const getOptionMeta = jest.fn().mockReturnValue({ readOnly: true, required: false });
const option: NotificationChannelOption = {
propertyName: 'testSubformArray',
label: 'Test Subform Array',
description: 'A test subform array',
element: 'subform_array',
inputType: '',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: false,
dependsOn: '',
subformOptions: [
{
propertyName: 'nestedField',
label: 'Nested Field',
description: 'A nested field',
element: 'input',
inputType: 'text',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: true,
dependsOn: '',
},
],
};
renderOptionField(option, { getOptionMeta, defaultValue: [{ nestedField: 'test' }] });
// The subform array should be rendered
expect(screen.getByText('Test Subform Array (1)')).toBeInTheDocument();
// Verify that getOptionMeta was called
expect(getOptionMeta).toHaveBeenCalled();
});
it('should display lock icon for protected fields inside subform array when readOnly', async () => {
const getOptionMeta = jest.fn((opt) => {
if (opt.protected) {
return { readOnly: true, required: false };
}
return { readOnly: false, required: false };
});
const option: NotificationChannelOption = {
propertyName: 'headers',
label: 'HTTP Headers',
description: 'Custom headers',
element: 'subform_array',
inputType: '',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: false,
dependsOn: '',
subformOptions: [
{
propertyName: 'authorization',
label: 'Authorization Header',
description: 'Auth header value',
element: 'input',
inputType: 'text',
placeholder: '',
required: false,
secure: false,
showWhen: { field: '', is: '' },
validationRule: '',
protected: true,
dependsOn: '',
},
],
};
renderOptionField(option, { getOptionMeta, defaultValue: [{ authorization: 'Bearer token' }] });
// Check that lock icon is displayed for the nested protected field
const lockIcon = screen.getByTestId('lock-icon');
expect(lockIcon).toBeInTheDocument();
// Hover over the icon to show tooltip
await userEvent.hover(lockIcon);
// Check that tooltip appears
await waitFor(() => {
expect(
screen.getByText('This field is protected and can only be edited by users with elevated permissions')
).toBeInTheDocument();
});
});
});
});
@@ -1,21 +1,24 @@
import { css } from '@emotion/css';
import { isEmpty } from 'lodash';
import { FC, useEffect } from 'react';
import { FC } from 'react';
import { Controller, DeepMap, FieldError, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import {
Checkbox,
Field,
Icon,
Input,
RadioButtonList,
SecretInput,
SecretTextArea,
Select,
Stack,
TextArea,
Tooltip,
useStyles2,
} from '@grafana/ui';
import { NotificationChannelOption, NotificationChannelSecureFields } from 'app/types';
import { t } from 'app/core/internationalization';
import { NotificationChannelOption, NotificationChannelSecureFields, OptionMeta } from 'app/types';
import { KeyValueMapInput } from './KeyValueMapInput';
import { StringArrayInput } from './StringArrayInput';
@@ -26,33 +29,30 @@ import { WrapWithTemplateSelection } from './TemplateSelector';
interface Props {
defaultValue: any;
option: NotificationChannelOption;
// this is defined if the option is rendered inside a subform
parentOption?: NotificationChannelOption;
getOptionMeta?: (option: NotificationChannelOption) => OptionMeta;
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;
onDeleteSubform?: (settingsPath: string, option: NotificationChannelOption) => void;
secureFields: NotificationChannelSecureFields;
}
export const OptionField: FC<Props> = ({
option,
parentOption,
invalid,
pathPrefix,
pathSuffix = '',
error,
defaultValue,
readOnly = false,
customValidator,
onResetSecureField,
secureFields = {},
secureFields,
onDeleteSubform,
getOptionMeta,
}) => {
const optionPath = `${pathPrefix}${pathSuffix}`;
if (option.element === 'subform') {
return (
<SubformField
@@ -62,73 +62,114 @@ export const OptionField: FC<Props> = ({
defaultValue={defaultValue}
option={option}
errors={error}
pathPrefix={optionPath}
pathPrefix={pathPrefix}
onDelete={onDeleteSubform}
getOptionMeta={getOptionMeta}
/>
);
}
if (option.element === 'subform_array') {
return (
<SubformArrayField
secureFields={secureFields}
readOnly={readOnly}
defaultValues={defaultValue}
option={option}
pathPrefix={optionPath}
pathPrefix={pathPrefix}
errors={error as Array<DeepMap<any, FieldError>> | undefined}
getOptionMeta={getOptionMeta}
/>
);
}
const shouldShowProtectedIndicator = option.protected && getOptionMeta?.(option).readOnly;
const labelText = option.element !== 'checkbox' && option.element !== 'radio' ? option.label : undefined;
const label = shouldShowProtectedIndicator ? (
<Stack direction="row" alignItems="center" gap={0.5}>
<Tooltip
content={t(
'alerting.receivers.protected.field.description',
'This field is protected and can only be edited by users with elevated permissions'
)}
>
<Icon size="sm" name="lock" data-testid="lock-icon" />
</Tooltip>
{labelText}
</Stack>
) : (
labelText
);
return (
<Field
label={option.element !== 'checkbox' && option.element !== 'radio' ? option.label : undefined}
label={label}
description={option.description || undefined}
invalid={!!error}
error={error?.message}
data-testid={`${optionPath}${option.propertyName}`}
data-testid={`${pathPrefix}${option.propertyName}`}
>
<OptionInput
id={`${optionPath}${option.propertyName}`}
id={`${pathPrefix}${option.propertyName}`}
defaultValue={defaultValue}
option={option}
invalid={invalid}
pathPrefix={optionPath}
pathPrefix={pathPrefix}
readOnly={readOnly}
pathIndex={pathPrefix}
parentOption={parentOption}
customValidator={customValidator}
onResetSecureField={onResetSecureField}
secureFields={secureFields}
getOptionMeta={getOptionMeta}
/>
</Field>
);
};
const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
const OptionInput: FC<Props & { id: string }> = ({
option,
invalid,
id,
pathPrefix = '',
pathIndex = '',
readOnly = false,
customValidator,
onResetSecureField,
secureFields = {},
parentOption,
getOptionMeta,
}) => {
const styles = useStyles2(getStyles);
const { control, register, unregister, getValues, setValue } = useFormContext();
const { control, register, setValue } = useFormContext();
const optionMeta = getOptionMeta?.(option);
const name = `${pathPrefix}${option.propertyName}`;
const nestedKey = parentOption ? `${parentOption.propertyName}.${option.propertyName}` : option.propertyName;
const isEncryptedInput = secureFields?.[nestedKey];
// For nested secure fields, construct the full path relative to settings
// e.g., if pathPrefix is "items.0.settings.sigv4." and propertyName is "access_key"
// we need to look for "sigv4.access_key" in secureFields
const getSecureFieldLookupKey = (): string => {
if (!option.secure) {
return '';
}
// workaround for https://github.com/react-hook-form/react-hook-form/issues/4993#issuecomment-829012506
useEffect(
() => () => {
unregister(name, { keepValue: false });
},
[unregister, name]
);
// Use secureFieldKey if explicitly set (from mockGrafanaNotifiers)
if (option.secureFieldKey) {
return option.secureFieldKey;
}
// Extract the path after "settings." to build the lookup key for nested fields
const settingsMatch = pathPrefix.match(/settings\.(.+)$/);
if (settingsMatch) {
const nestedPath = settingsMatch[1];
return `${nestedPath}${option.propertyName}`;
}
// Default to just the property name for non-nested fields
return option.propertyName;
};
const secureFieldKey = getSecureFieldLookupKey();
const isEncryptedInput = secureFieldKey && secureFields?.[secureFieldKey];
const useTemplates = option.placeholder.includes('{{ template');
@@ -158,15 +199,15 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
onSelectTemplate={onSelectTemplate}
>
{isEncryptedInput ? (
<SecretInput onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
<SecretInput id={id} onReset={() => onResetSecureField?.(secureFieldKey)} isConfigured />
) : (
<Input
id={id}
readOnly={readOnly || useTemplates || determineReadOnly(option, getValues, pathIndex)}
readOnly={readOnly || useTemplates || optionMeta?.readOnly}
invalid={invalid}
type={option.inputType}
{...register(name, {
required: determineRequired(option, getValues, pathIndex),
required: optionMeta?.required,
validate: {
validationRule: (v) =>
option.validationRule ? validateOption(v, option.validationRule, option.required) : true,
@@ -233,7 +274,7 @@ const OptionInput: FC<Props & { id: string; pathIndex?: string }> = ({
onSelectTemplate={onSelectTemplate}
>
{isEncryptedInput ? (
<SecretTextArea onReset={() => onResetSecureField?.(nestedKey)} isConfigured />
<SecretTextArea id={id} onReset={() => onResetSecureField?.(secureFieldKey)} isConfigured />
) : (
<TextArea
id={id}
@@ -292,30 +333,3 @@ const validateOption = (value: string, validationRule: string, required: boolean
return RegExp(validationRule).test(value) ? true : 'Invalid format';
};
const determineRequired = (option: NotificationChannelOption, getValues: any, pathIndex: string) => {
const secureFields = getValues(`${pathIndex}secureFields`);
const secureSettings = getValues(`${pathIndex}secureSettings`);
if (!option.dependsOn) {
return option.required ? 'Required' : false;
}
if (isEmpty(secureFields) || !secureFields[option.dependsOn]) {
const dependentOn = Boolean(secureSettings[option.dependsOn]);
return !dependentOn && option.required ? 'Required' : false;
} else {
const dependentOn = Boolean(secureFields[option.dependsOn]);
return !dependentOn && option.required ? 'Required' : false;
}
};
const determineReadOnly = (option: NotificationChannelOption, getValues: any, pathIndex: string) => {
if (!option.dependsOn) {
return false;
}
if (isEmpty(getValues(`${pathIndex}secureFields`))) {
return getValues(`${pathIndex}secureSettings.${option.dependsOn}`);
} else {
return getValues(`${pathIndex}secureFields.${option.dependsOn}`);
}
};
@@ -3,7 +3,7 @@ import { DeepMap, FieldError, useFormContext } from 'react-hook-form';
import { Button, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { useControlledFieldArray } from 'app/features/alerting/unified/hooks/useControlledFieldArray';
import { NotificationChannelOption } from 'app/types';
import { NotificationChannelOption, NotificationChannelSecureFields, OptionMeta } from 'app/types';
import { ActionIcon } from '../../../rules/ActionIcon';
import { CollapsibleSection } from '../CollapsibleSection';
@@ -17,9 +17,19 @@ interface Props {
pathPrefix: string;
errors?: Array<DeepMap<any, FieldError>>;
readOnly?: boolean;
secureFields: NotificationChannelSecureFields;
getOptionMeta?: (option: NotificationChannelOption) => OptionMeta;
}
export const SubformArrayField = ({ option, pathPrefix, errors, defaultValues, readOnly = false }: Props) => {
export const SubformArrayField = ({
option,
pathPrefix,
errors,
defaultValues,
readOnly = false,
secureFields,
getOptionMeta,
}: Props) => {
const styles = useStyles2(getReceiverFormFieldStyles);
const path = `${pathPrefix}${option.propertyName}`;
const formAPI = useFormContext();
@@ -47,6 +57,8 @@ export const SubformArrayField = ({ option, pathPrefix, errors, defaultValues, r
{option.subformOptions?.map((option) => (
<OptionField
readOnly={readOnly}
getOptionMeta={getOptionMeta}
secureFields={secureFields}
defaultValue={field?.[option.propertyName]}
key={option.propertyName}
option={option}
@@ -3,7 +3,7 @@ import { DeepMap, FieldError, useFormContext } from 'react-hook-form';
import { Button, useStyles2 } from '@grafana/ui';
import { Trans, t } from 'app/core/internationalization';
import { NotificationChannelOption, NotificationChannelSecureFields } from 'app/types';
import { NotificationChannelOption, NotificationChannelSecureFields, OptionMeta } from 'app/types';
import { ActionIcon } from '../../../rules/ActionIcon';
@@ -13,10 +13,16 @@ import { getReceiverFormFieldStyles } from './styles';
interface Props {
defaultValue: any;
option: NotificationChannelOption;
getOptionMeta?: (option: NotificationChannelOption) => OptionMeta;
pathPrefix: string;
errors?: DeepMap<any, FieldError>;
readOnly?: boolean;
secureFields?: NotificationChannelSecureFields;
secureFields: NotificationChannelSecureFields;
/**
* Callback function to delete a subform field. Removal requires side effects
* like settings and secure fields cleanup.
*/
onDelete?: (settingsPath: string, option: NotificationChannelOption) => void;
onResetSecureField?: (propertyName: string) => void;
}
@@ -25,8 +31,10 @@ export const SubformField = ({
pathPrefix,
errors,
defaultValue,
getOptionMeta,
readOnly = false,
secureFields = {},
secureFields,
onDelete,
onResetSecureField,
}: Props) => {
const styles = useStyles2(getReceiverFormFieldStyles);
@@ -37,18 +45,23 @@ export const SubformField = ({
const [show, setShow] = useState(!!value);
const onDeleteClick = () => {
onDelete?.(name, option);
setShow(false);
};
return (
<div className={styles.wrapper} data-testid={`${name}.container`}>
<h6>{option.label}</h6>
{option.description && <p className={styles.description}>{option.description}</p>}
{show && (
<>
{!readOnly && (
{!readOnly && onDelete && (
<ActionIcon
data-testid={`${name}.delete-button`}
icon="trash-alt"
tooltip={t('alerting.subform-field.tooltip-delete', 'delete')}
onClick={() => setShow(false)}
onClick={onDeleteClick}
className={styles.deleteIcon}
/>
)}
@@ -56,10 +69,11 @@ export const SubformField = ({
return (
<OptionField
readOnly={readOnly}
secureFields={secureFields}
getOptionMeta={getOptionMeta}
onResetSecureField={onResetSecureField}
onDeleteSubform={onDelete}
secureFields={secureFields}
defaultValue={defaultValue?.[subOption.propertyName]}
parentOption={option}
key={subOption.propertyName}
option={subOption}
pathPrefix={`${name}.`}
@@ -49,7 +49,6 @@ function createContactPoint(httpConfig: DeprecatedAuthHTTPConfig | HTTPAuthConfi
{
__id: '',
type: '',
secureSettings: {},
secureFields: {},
settings: {
http_config: {
@@ -2693,6 +2693,7 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
required: false,
validationRule: '',
secure: true,
secureFieldKey: 'tlsConfig.caCertificate',
dependsOn: '',
},
{
@@ -2710,6 +2711,7 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
required: false,
validationRule: '',
secure: true,
secureFieldKey: 'tlsConfig.clientCertificate',
dependsOn: '',
},
{
@@ -2727,6 +2729,7 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
required: false,
validationRule: '',
secure: true,
secureFieldKey: 'tlsConfig.clientKey',
dependsOn: '',
},
],
@@ -3026,6 +3029,7 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
required: false,
validationRule: '',
secure: true,
secureFieldKey: 'sigv4.access_key',
dependsOn: '',
subformOptions: undefined,
},
@@ -3044,6 +3048,7 @@ export const grafanaAlertNotifiers: Record<GrafanaNotifierType, NotifierDTO> = {
required: false,
validationRule: '',
secure: true,
secureFieldKey: 'sigv4.secret_key',
dependsOn: '',
subformOptions: undefined,
},
@@ -9,8 +9,7 @@ export interface ChannelValues {
__id: string; // used to correlate form values to original DTOs
type: string;
settings: Record<string, any>;
secureSettings: Record<string, any>;
secureFields: Record<string, boolean>;
secureFields: Record<string, boolean | ''>;
}
export interface ReceiverFormValues<R extends ChannelValues> {
@@ -6,10 +6,10 @@ exports[`formValuesToGrafanaReceiver should migrate regular settings to secure s
{
"disableResolveMessage": false,
"name": "my-receiver",
"secureSettings": {
"secureFields": {},
"settings": {
"url": "https://foo.bar/",
},
"settings": {},
"type": "discord",
"uid": "abc123",
},
@@ -21,6 +21,8 @@ export enum K8sAnnotations {
AccessAdmin = 'grafana.com/access/canAdmin',
/** Annotation key that indicates that the calling user is able to delete this entity */
AccessDelete = 'grafana.com/access/canDelete',
/** Annotation key that indicates that the calling user is able to modify protected fields of this entity */
AccessModifyProtected = 'grafana.com/access/canModifyProtected',
}
/**
@@ -42,6 +42,9 @@ export const canAdminEntity = (k8sEntity: EntityToCheck) =>
export const canDeleteEntity = (k8sEntity: EntityToCheck) =>
getAnnotation(k8sEntity, K8sAnnotations.AccessDelete) === 'true';
export const canModifyProtectedEntity = (k8sEntity: EntityToCheck) =>
getAnnotation(k8sEntity, K8sAnnotations.AccessModifyProtected) === 'true';
/**
* Escape \ and = characters for field selectors.
* The Kubernetes API Machinery will decode those automatically.
@@ -1,7 +1,7 @@
import { NotifierDTO } from 'app/types';
import { GrafanaManagedContactPoint, Receiver } from '../../../../plugins/datasource/alertmanager/types';
import { grafanaAlertNotifiers, grafanaAlertNotifiersMock } from '../mockGrafanaNotifiers';
import { grafanaAlertNotifiers } from '../mockGrafanaNotifiers';
import { CloudChannelValues, GrafanaChannelValues, ReceiverFormValues } from '../types/receiver-form';
import {
@@ -172,7 +172,6 @@ describe('formValuesToGrafanaReceiver', () => {
items: [
{
__id: '1',
secureSettings: {},
secureFields: {},
type: 'discord',
settings: {
@@ -186,7 +185,6 @@ describe('formValuesToGrafanaReceiver', () => {
const channelMap = {
'1': {
uid: 'abc123',
secureSettings: {},
secureFields: {},
type: 'discord',
settings: {
@@ -222,7 +220,6 @@ describe('formValuesToCloudReceiver', () => {
fields: [{ __id: '10', title: 'priority', value: '1' }],
},
secureFields: {},
secureSettings: {},
sendResolved: true,
},
],
@@ -235,7 +232,6 @@ describe('formValuesToCloudReceiver', () => {
url: 'https://slack.example.com/',
},
secureFields: {},
secureSettings: {},
sendResolved: true,
};
@@ -256,7 +252,7 @@ describe('formValuesToCloudReceiver', () => {
});
describe('grafanaReceiverToFormValues', () => {
const { googlechat, slack, sns } = grafanaAlertNotifiers;
const { slack, sns } = grafanaAlertNotifiers;
it('should convert fields from settings and secureFields', () => {
const slackReceiver: GrafanaManagedContactPoint = {
@@ -274,11 +270,10 @@ describe('grafanaReceiverToFormValues', () => {
],
};
const [formValues, _] = grafanaReceiverToFormValues(slackReceiver, grafanaAlertNotifiersMock);
const [formValues, _] = grafanaReceiverToFormValues(slackReceiver);
expect(formValues.items[0].type).toBe(slack.type);
expect(formValues.items[0].settings.recipient).toBe('#alerting-ops');
expect(formValues.items[0].secureFields.token).toBe(true);
expect(formValues.items[0].secureSettings).toEqual({});
});
it('should convert nested settings and secureFields', () => {
@@ -300,7 +295,7 @@ describe('grafanaReceiverToFormValues', () => {
],
};
const [formValues, _] = grafanaReceiverToFormValues(snsReceiver, grafanaAlertNotifiersMock);
const [formValues, _] = grafanaReceiverToFormValues(snsReceiver);
expect(formValues.items[0].settings.api_url).toBe('https://sns.example.com/');
expect(formValues.items[0].settings.phone_number).toBe('+1234567890');
@@ -308,26 +303,6 @@ describe('grafanaReceiverToFormValues', () => {
expect(formValues.items[0].secureFields['sigv4.access_key']).toBe(true);
expect(formValues.items[0].secureFields['sigv4.secret_key']).toBe(true);
});
// Some receivers have migrated options that are now marked as secure but were standard fields in the past
// We need to handle the case where the field is still present in settings but marked as secure
it('should convert fields from settings to secureSettings for migrated options', () => {
const googleChatReceiver: GrafanaManagedContactPoint = {
name: 'googlechat-receiver',
grafana_managed_receiver_configs: [
{
type: googlechat.type,
settings: {
url: 'https://googlechat.example.com/',
},
},
],
};
const [formValues, _] = grafanaReceiverToFormValues(googleChatReceiver, grafanaAlertNotifiersMock);
expect(formValues.items[0].secureSettings.url).toBe('https://googlechat.example.com/');
expect(formValues.items[0].settings.url).toBeUndefined();
});
});
describe('convertJsonToJiraField', () => {
@@ -1,14 +1,16 @@
import { get, has, isArray, isNil, omit, omitBy, reduce } from 'lodash';
import { has, isArray, isNil, omitBy, pickBy } from 'lodash';
import {
AlertmanagerReceiver,
GrafanaManagedContactPoint,
GrafanaManagedReceiverConfig,
GrafanaManagedReceiverSecureFields,
Receiver,
} from 'app/plugins/datasource/alertmanager/types';
import { CloudNotifierType, NotificationChannelOption, NotifierDTO, NotifierType } from 'app/types';
import { CloudNotifierType, NotificationChannelOption, NotifierDTO, NotifierType } from 'app/types/alerting';
import {
ChannelValues,
CloudChannelConfig,
CloudChannelMap,
CloudChannelValues,
@@ -18,8 +20,7 @@ import {
} from '../types/receiver-form';
export function grafanaReceiverToFormValues(
receiver: GrafanaManagedContactPoint,
notifiers: NotifierDTO[]
receiver: GrafanaManagedContactPoint
): [ReceiverFormValues<GrafanaChannelValues>, GrafanaChannelMap] {
const channelMap: GrafanaChannelMap = {};
// giving each form receiver item a unique id so we can use it to map back to "original" items
@@ -32,8 +33,7 @@ export function grafanaReceiverToFormValues(
receiver.grafana_managed_receiver_configs?.map((channel) => {
const id = String(idCounter++);
channelMap[id] = channel;
const notifier = notifiers.find(({ type }) => type === channel.type);
return grafanaChannelConfigToFormChannelValues(id, channel, notifier);
return grafanaChannelConfigToFormChannelValues(id, channel);
}) ?? [],
};
return [values, channelMap];
@@ -77,22 +77,14 @@ export function cloudReceiverToFormValues(
export function formValuesToGrafanaReceiver(
values: ReceiverFormValues<GrafanaChannelValues>,
channelMap: GrafanaChannelMap,
defaultChannelValues: GrafanaChannelValues,
notifiers: NotifierDTO[]
): Receiver {
defaultChannelValues: GrafanaChannelValues
): GrafanaManagedContactPoint {
return {
name: values.name,
grafana_managed_receiver_configs: (values.items ?? []).map((channelValues) => {
const existing: GrafanaManagedReceiverConfig | undefined = channelMap[channelValues.__id];
const notifier = notifiers.find((notifier) => notifier.type === channelValues.type);
return formChannelValuesToGrafanaChannelConfig(
channelValues,
defaultChannelValues,
values.name,
existing,
notifier
);
return formChannelValuesToGrafanaChannelConfig(channelValues, defaultChannelValues, values.name, existing);
}),
};
}
@@ -100,7 +92,7 @@ export function formValuesToGrafanaReceiver(
export function formValuesToCloudReceiver(
values: ReceiverFormValues<CloudChannelValues>,
defaults: CloudChannelValues
): Receiver {
): AlertmanagerReceiver {
const recv: AlertmanagerReceiver = {
name: values.name,
};
@@ -177,33 +169,23 @@ function cloudChannelConfigToFormChannelValues(
...(type === 'jira' ? convertJsonToJiraField(channel) : channel),
},
secureFields: {},
secureSettings: {},
sendResolved: channel.send_resolved,
};
}
function grafanaChannelConfigToFormChannelValues(
id: string,
channel: GrafanaManagedReceiverConfig,
notifier?: NotifierDTO
channel: GrafanaManagedReceiverConfig
): GrafanaChannelValues {
const values: GrafanaChannelValues = {
__id: id,
type: channel.type as NotifierType,
provenance: channel.provenance,
secureSettings: {},
settings: { ...channel.settings },
secureFields: { ...channel.secureFields },
disableResolveMessage: channel.disableResolveMessage,
};
notifier?.options.forEach((option) => {
if (option.secure && values.settings[option.propertyName]) {
values.secureSettings[option.propertyName] = values.settings[option.propertyName];
delete values.settings[option.propertyName];
}
});
return values;
}
@@ -241,43 +223,22 @@ export function formChannelValuesToGrafanaChannelConfig(
values: GrafanaChannelValues,
defaults: GrafanaChannelValues,
name: string,
existing?: GrafanaManagedReceiverConfig,
notifier?: NotifierDTO
existing?: GrafanaManagedReceiverConfig
): GrafanaManagedReceiverConfig {
const secureFieldsFromValues = values.secureFields ? omitFalsySecureFields(values.secureFields) : undefined;
const channel: GrafanaManagedReceiverConfig = {
settings: omitEmptyValues({
...(existing && existing.type === values.type ? (existing.settings ?? {}) : {}),
...(values.settings ?? {}),
}),
secureSettings: omitEmptyUnlessExisting(values.secureSettings, existing?.secureFields),
secureFields: secureFieldsFromValues,
type: values.type,
name,
disableResolveMessage:
values.disableResolveMessage ?? existing?.disableResolveMessage ?? defaults.disableResolveMessage,
};
// find all secure field definitions
const secureFieldNames = notifier ? getSecureFieldNames(notifier) : [];
// we make sure all fields that are marked as "secure" will be moved to "SecureSettings" instead of "settings"
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 = {
...secureSettings,
...channel.secureSettings,
};
// remove the secure ones from the regular settings
channel.settings = omit(channel.settings, secureFieldNames);
if (existing) {
channel.uid = existing.uid;
}
@@ -285,6 +246,13 @@ export function formChannelValuesToGrafanaChannelConfig(
return channel;
}
/**
* Omit falsy values from secure fields object so the backend knows to reset them
*/
function omitFalsySecureFields(secureFields: ChannelValues['secureFields']): GrafanaManagedReceiverSecureFields {
return pickBy(secureFields, (value) => value === true);
}
// null, undefined and '' are deemed unacceptable
const isUnacceptableValue = (value: unknown) => isNil(value) || value === '';
@@ -69,11 +69,16 @@ export type WebhookConfig = {
};
type GrafanaManagedReceiverConfigSettings<T = any> = Record<string, T>;
export type GrafanaManagedReceiverSecureFields = Record<string, boolean>;
export type GrafanaManagedReceiverConfig = {
uid?: string;
disableResolveMessage?: boolean;
secureFields?: Record<string, boolean>;
secureSettings?: GrafanaManagedReceiverConfigSettings;
/**
* Secure fields keys values should be true if they are already configured in the database
* To reset the secure field, omit the key from the object when updating the receiver
*/
secureFields?: GrafanaManagedReceiverSecureFields;
/** If retrieved from k8s API, SecureSettings property name is different */
// SecureSettings?: GrafanaManagedReceiverConfigSettings<boolean>;
settings: GrafanaManagedReceiverConfigSettings;
+1
View File
@@ -136,6 +136,7 @@ export enum AccessControlAction {
AlertingReceiversCreate = 'alert.notifications.receivers:create',
AlertingReceiversWrite = 'alert.notifications.receivers:write',
AlertingReceiversRead = 'alert.notifications.receivers:read',
AlertingReceiversUpdateProtected = 'alert.notifications.receivers.protected:write',
// Alerting routes actions
AlertingRoutesRead = 'alert.notifications.routes:read',
+14
View File
@@ -1,3 +1,5 @@
import { ValidationRule } from 'react-hook-form';
import { SelectableValue } from '@grafana/data';
import { IconName } from '@grafana/ui';
@@ -123,6 +125,11 @@ export interface ChannelTypeSettings {
uploadImage: boolean;
}
export interface OptionMeta {
required?: string | ValidationRule<boolean>;
readOnly?: boolean;
}
export interface NotificationChannelOption {
element:
| 'input'
@@ -141,6 +148,13 @@ export interface NotificationChannelOption {
propertyName: string;
required: boolean;
secure: boolean;
secureFieldKey?: string;
/**
* protected indicates that only administrators or users with
* "alert.notifications.receivers.protected:write" permission
* are allowed to update this field
* */
protected?: boolean;
selectOptions?: Array<SelectableValue<string>> | null;
defaultValue?: SelectableValue<string>;
showWhen: { field: string; is: string | boolean };
+10
View File
@@ -1547,6 +1547,16 @@
"title-attention": "Attention",
"title-manage-contact-point-permissions": "Manage contact point permissions"
},
"receiver-metadata-badge": {
"aria-label-open-external-link": "Open external link"
},
"receivers": {
"protected": {
"field": {
"description": "This field is protected and can only be editor by users with elevated permissions"
}
}
},
"receivers-section": {
"new-menu": {
"label-export-all": "Export all"