diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 478996bdba1..9732c49c3d9 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1555,17 +1555,6 @@ "public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 - }, - "@typescript-eslint/no-explicit-any": { - "count": 2 - }, - "no-restricted-syntax": { - "count": 1 - } - }, - "public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx": { - "no-restricted-syntax": { - "count": 1 } }, "public/app/features/alerting/unified/components/receivers/form/CloudCommonChannelSettings.tsx": { @@ -1583,17 +1572,6 @@ "count": 1 } }, - "public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 2 - }, - "@typescript-eslint/no-explicit-any": { - "count": 1 - }, - "no-restricted-syntax": { - "count": 1 - } - }, "public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 diff --git a/pkg/registry/apps/alerting/notifications/receiver/conversions.go b/pkg/registry/apps/alerting/notifications/receiver/conversions.go index 04b85a5af79..6413cd6f950 100644 --- a/pkg/registry/apps/alerting/notifications/receiver/conversions.go +++ b/pkg/registry/apps/alerting/notifications/receiver/conversions.go @@ -107,10 +107,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) { diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 2afc2a9e935..1a6444a3d98 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -459,6 +459,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" diff --git a/pkg/services/accesscontrol/ossaccesscontrol/receivers.go b/pkg/services/accesscontrol/ossaccesscontrol/receivers.go index 3ef4638b3dc..03d81818fa7 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/receivers.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/receivers.go @@ -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 { diff --git a/pkg/services/ngalert/accesscontrol.go b/pkg/services/ngalert/accesscontrol.go index 259db2bd5a9..82fe1483e15 100644 --- a/pkg/services/ngalert/accesscontrol.go +++ b/pkg/services/ngalert/accesscontrol.go @@ -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)}, diff --git a/pkg/services/ngalert/accesscontrol/receivers.go b/pkg/services/ngalert/accesscontrol/receivers.go index 1016f246a41..0f2b36e4086 100644 --- a/pkg/services/ngalert/accesscontrol/receivers.go +++ b/pkg/services/ngalert/accesscontrol/receivers.go @@ -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 diff --git a/pkg/services/ngalert/accesscontrol/receivers_test.go b/pkg/services/ngalert/accesscontrol/receivers_test.go index 6ad2e1db463..2eb2ab54395 100644 --- a/pkg/services/ngalert/accesscontrol/receivers_test.go +++ b/pkg/services/ngalert/accesscontrol/receivers_test.go @@ -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", diff --git a/pkg/services/ngalert/api/api_alertmanager.go b/pkg/services/ngalert/api/api_alertmanager.go index 90dcb26fc74..c8712e8c11a 100644 --- a/pkg/services/ngalert/api/api_alertmanager.go +++ b/pkg/services/ngalert/api/api_alertmanager.go @@ -12,12 +12,14 @@ import ( alertingNotify "github.com/grafana/alerting/notify" "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" 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 +34,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,11 +213,16 @@ 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, "") } + if errors.As(err, &errutil.Error{}) { + return response.Err(err) + } return ErrResp(http.StatusInternalServerError, err, "failed to post process Alertmanager configuration") } diff --git a/pkg/services/ngalert/models/permissions.go b/pkg/services/ngalert/models/permissions.go index a34e184fc99..ce52755eac8 100644 --- a/pkg/services/ngalert/models/permissions.go +++ b/pkg/services/ngalert/models/permissions.go @@ -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, } } diff --git a/pkg/services/ngalert/models/receivers.go b/pkg/services/ngalert/models/receivers.go index 56c6ad06841..14ac3125c05 100644 --- a/pkg/services/ngalert/models/receivers.go +++ b/pkg/services/ngalert/models/receivers.go @@ -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. @@ -162,9 +167,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 @@ -241,9 +247,10 @@ func IntegrationConfigFromType(integrationType string, version *string) (Integra 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) @@ -318,9 +325,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() @@ -683,3 +691,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.With(keyStr) + // Recurse if the sub-value is another map + if m, ok := r.getMap(iter.Value()); ok { + result = append(result, r.expandPaths(p, m)...) + continue + } + result = append(result, p) + } + return result +} + +func (r IntegrationDiffReport) getMap(v reflect.Value) (reflect.Value, bool) { + if v.Kind() == reflect.Map { + return v, true + } + if v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface { + return r.getMap(v.Elem()) + } + return reflect.Value{}, false +} + +func (r IntegrationDiffReport) needExpand(diff cmputil.Diff) (reflect.Value, bool) { + ml, lok := r.getMap(diff.Left) + mr, rok := r.getMap(diff.Right) + if lok == rok { + return reflect.Value{}, false + } + if lok { + return ml, true + } + return mr, true +} + +func (r IntegrationDiffReport) GetSettingsPaths() []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 +} diff --git a/pkg/services/ngalert/models/receivers_test.go b/pkg/services/ngalert/models/receivers_test.go index d3db9f89189..df8b1889283 100644 --- a/pkg/services/ngalert/models/receivers_test.go +++ b/pkg/services/ngalert/models/receivers_test.go @@ -3,6 +3,7 @@ package models import ( "maps" "reflect" + "slices" "testing" alertingNotify "github.com/grafana/alerting/notify" @@ -437,3 +438,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": "", + }))(), + 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) + }) + } +} diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go index 19a26ec8c53..fab55fc61d4 100644 --- a/pkg/services/ngalert/models/testing.go +++ b/pkg/services/ngalert/models/testing.go @@ -1400,3 +1400,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) + } +} diff --git a/pkg/services/ngalert/notifier/alertmanager_config.go b/pkg/services/ngalert/notifier/alertmanager_config.go index 3d4ebba01f9..dbfc3797d44 100644 --- a/pkg/services/ngalert/notifier/alertmanager_config.go +++ b/pkg/services/ngalert/notifier/alertmanager_config.go @@ -324,7 +324,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) } diff --git a/pkg/services/ngalert/notifier/channels_config/available_channels.go b/pkg/services/ngalert/notifier/channels_config/available_channels.go index 6e7182e1f10..459d984c521 100644 --- a/pkg/services/ngalert/notifier/channels_config/available_channels.go +++ b/pkg/services/ngalert/notifier/channels_config/available_channels.go @@ -287,6 +287,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { PropertyName: "url", Required: true, Secure: true, + Protected: true, }, { Label: "Message Type", @@ -333,6 +334,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { Placeholder: "http://localhost:8082", PropertyName: "kafkaRestProxy", Required: true, + Protected: true, }, { Label: "Topic", @@ -533,6 +535,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { InputType: InputTypeText, Placeholder: alertingPagerduty.DefaultURL, PropertyName: "url", + Protected: true, }, }, }, @@ -550,6 +553,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { PropertyName: "url", Required: true, Secure: true, + Protected: true, }, { // New in 8.0. Label: "Message Type", @@ -595,6 +599,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { InputType: InputTypeText, PropertyName: "url", Required: true, + Protected: true, }, { Label: "HTTP Method", @@ -844,6 +849,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { Secure: true, Required: true, DependsOn: "token", + Protected: true, }, { // New in 8.4. Label: "Endpoint URL", @@ -852,6 +858,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", @@ -891,6 +898,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { Placeholder: "http://sensu-api.local:8080", PropertyName: "url", Required: true, + Protected: true, }, { Label: "API Key", @@ -949,6 +957,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { Placeholder: "Teams incoming webhook url", PropertyName: "url", Required: true, + Protected: true, }, { Label: "Title", @@ -1067,6 +1076,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { InputType: InputTypeText, PropertyName: "url", Required: true, + Protected: true, }, { Label: "HTTP Method", @@ -1227,6 +1237,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { Secure: true, Required: true, DependsOn: "secret", + Protected: true, }, { Label: "Agent ID", @@ -1312,6 +1323,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { Placeholder: "http://localhost:9093", PropertyName: "url", Required: true, + Protected: true, }, { Label: "Basic Auth User", @@ -1358,6 +1370,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { PropertyName: "url", Required: true, Secure: true, + Protected: true, }, { Label: "Avatar URL", @@ -1387,6 +1400,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { PropertyName: "url", Required: true, Secure: true, + Protected: true, }, { Label: "Title", @@ -1507,6 +1521,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { Description: "The URL of the MQTT broker.", PropertyName: "brokerUrl", Required: true, + Protected: true, }, { Label: "Topic", @@ -1665,6 +1680,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { Placeholder: "https://api.opsgenie.com/v2/alerts", PropertyName: "apiUrl", Required: true, + Protected: true, }, { Label: "Message", @@ -1761,6 +1777,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", @@ -1795,7 +1812,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", @@ -1917,6 +1934,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { PropertyName: "api_url", Description: "Supported v2 or v3 APIs", Required: true, + Protected: true, }, { Label: "HTTP Basic Authentication - Username", diff --git a/pkg/services/ngalert/notifier/channels_config/plugin.go b/pkg/services/ngalert/notifier/channels_config/plugin.go index f7332bdcfd2..256f21492a4 100644 --- a/pkg/services/ngalert/notifier/channels_config/plugin.go +++ b/pkg/services/ngalert/notifier/channels_config/plugin.go @@ -65,6 +65,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. diff --git a/pkg/services/ngalert/notifier/crypto.go b/pkg/services/ngalert/notifier/crypto.go index 5d682ad8f62..af1b3dfae97 100644 --- a/pkg/services/ngalert/notifier/crypto.go +++ b/pkg/services/ngalert/notifier/crypto.go @@ -10,7 +10,9 @@ 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/notifier/legacy_storage" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/secrets" ) @@ -25,16 +27,18 @@ const ( cryptoPrefix = "crypto_" ) +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) Decrypt(ctx context.Context, payload []byte) ([]byte, error) EncryptExtraConfigs(ctx context.Context, config *definitions.PostableUserConfig) error DecryptExtraConfigs(ctx context.Context, config *definitions.PostableUserConfig) 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. @@ -53,7 +57,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 @@ -64,7 +68,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 } @@ -166,7 +170,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 { @@ -175,10 +179,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...") @@ -208,6 +212,33 @@ func (c *alertmanagerCrypto) LoadSecureSettings(ctx context.Context, orgId int64 return UnknownReceiverError{UID: gr.UID} } + if authorizeProtected != nil { + incoming, errIn := legacy_storage.PostableGrafanaReceiverToIntegration(gr) + existing, errEx := legacy_storage.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 { diff --git a/pkg/services/ngalert/notifier/receiver_svc.go b/pkg/services/ngalert/notifier/receiver_svc.go index a2332cbe6cd..a0648b285c4 100644 --- a/pkg/services/ngalert/notifier/receiver_svc.go +++ b/pkg/services/ngalert/notifier/receiver_svc.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "errors" "fmt" + "slices" "strings" "go.opentelemetry.io/otel/attribute" @@ -74,6 +75,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) } @@ -433,6 +437,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 @@ -721,3 +737,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 +} diff --git a/pkg/services/ngalert/notifier/receiver_svc_test.go b/pkg/services/ngalert/notifier/receiver_svc_test.go index f7d2a384962..c0a4e063378 100644 --- a/pkg/services/ngalert/notifier/receiver_svc_test.go +++ b/pkg/services/ngalert/notifier/receiver_svc_test.go @@ -543,8 +543,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{ @@ -1134,7 +1135,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()))() @@ -1146,8 +1147,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", @@ -1235,6 +1236,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 { @@ -1260,7 +1298,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) diff --git a/pkg/services/sqlstore/migrations/accesscontrol/alerting.go b/pkg/services/sqlstore/migrations/accesscontrol/alerting.go index f9942fac80e..deddf59eb12 100644 --- a/pkg/services/sqlstore/migrations/accesscontrol/alerting.go +++ b/pkg/services/sqlstore/migrations/accesscontrol/alerting.go @@ -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{}) +} diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index e907ddf7180..07e8e3c2e8c 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -155,4 +155,6 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) { ualert.DropTitleUniqueIndexMigration(mg) ualert.AddStateFiredAtColumn(mg) + + accesscontrol.AddReceiverProtectedFieldsEditor(mg) } diff --git a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go index 16ae89f7dff..8260a1d16c2 100644 --- a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go +++ b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go @@ -146,7 +146,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() @@ -407,13 +407,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{}) @@ -476,20 +477,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, @@ -538,22 +541,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, }, } @@ -611,6 +616,9 @@ func TestIntegrationAccessControl(t *testing.T) { if tc.canUpdate { expectedWithMetadata.SetAccessControl("canWrite") } + if tc.canUpdateProtected { + expectedWithMetadata.SetAccessControl("canModifyProtected") + } if tc.canDelete { expectedWithMetadata.SetAccessControl("canDelete") } @@ -674,6 +682,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.ReceiverIntegration{ + 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{}) @@ -686,6 +720,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)}} @@ -1293,6 +1328,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. diff --git a/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx b/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx index e1a1aa5aeba..ad6ee94d73e 100644 --- a/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx +++ b/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx @@ -1,6 +1,6 @@ import { Route, Routes } from 'react-router-dom-v5-compat'; import { render, screen } from 'test/test-utils'; -import { byLabelText, byPlaceholderText, byRole, byTestId } from 'testing-library-selector'; +import { byPlaceholderText, byRole, byTestId } from 'testing-library-selector'; import { captureRequests } from 'app/features/alerting/unified/mocks/server/events'; import { AccessControlAction } from 'app/types/accessControl'; @@ -122,7 +122,7 @@ const ui = { inputs: { name: byPlaceholderText('Name'), email: { - addresses: byLabelText(/Addresses/), + addresses: byRole('textbox', { name: /^Addresses/ }), }, }, }; diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx index fbdfdbbf493..fe2eae76d01 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelOptions.tsx @@ -29,8 +29,8 @@ export interface Props { * 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['customValidator']>; } @@ -43,6 +43,7 @@ export function ChannelOptions({ integrationPrefix, readOnly = false, customValidators = {}, + canEditProtectedFields, }: Props): JSX.Element { const { watch } = useFormContext>(); @@ -53,7 +54,7 @@ export function ChannelOptions({ const getOptionMeta = (option: NotificationChannelOption): OptionMeta => ({ required: determineRequired(option, settings, secureFields), - readOnly: determineReadOnly(option, settings, secureFields), + readOnly: determineReadOnly(option, settings, secureFields, canEditProtectedFields), }); return ( @@ -77,6 +78,7 @@ export function ChannelOptions({ label={option.label} description={option.description} htmlFor={`${settingsPath}${option.propertyName}`} + noMargin > ({ ); } - const error: FieldError | DeepMap | undefined = ( - (option.secure ? errors?.secureFields : errors?.settings) as DeepMap | undefined - )?.[option.secureFieldKey ?? 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, FieldError>> | undefined + )?.[propertyKey]; const defaultValue = defaultValues?.settings?.[option.propertyName]; @@ -139,8 +144,14 @@ const determineRequired = ( const determineReadOnly = ( option: NotificationChannelOption, settings: Record, - secureFields: NotificationChannelSecureFields + 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; } diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx index ca939d25d38..56f554f607e 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx @@ -36,6 +36,7 @@ interface Props { onDelete?: () => void; isEditable?: boolean; isTestable?: boolean; + canEditProtectedFields: boolean; customValidators?: React.ComponentProps['customValidators']; } @@ -53,6 +54,7 @@ export function ChannelSubForm({ commonSettingsComponent: CommonSettingsComponent, isEditable = true, isTestable, + canEditProtectedFields, customValidators = {}, }: Props): JSX.Element { const styles = useStyles2(getStyles); @@ -181,6 +183,7 @@ export function ChannelSubForm({ label={t('alerting.channel-sub-form.label-integration', 'Integration')} htmlFor={contactPointTypeInputId} data-testid={`${pathPrefix}type`} + noMargin > ({ onDeleteSubform={onDeleteSubform} integrationPrefix={channelFieldPath} readOnly={!isEditable} + canEditProtectedFields={canEditProtectedFields} customValidators={customValidators} /> {!!(mandatoryOptions.length && optionalOptions.length) && ( @@ -279,6 +283,7 @@ export function ChannelSubForm({ errors={errors} integrationPrefix={channelFieldPath} readOnly={!isEditable} + canEditProtectedFields={canEditProtectedFields} customValidators={customValidators} /> diff --git a/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx index 6512dcf86b3..ea490821452 100644 --- a/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/CloudReceiverForm.tsx @@ -91,6 +91,7 @@ export const CloudReceiverForm = ({ contactPoint, alertManagerSourceName, readOn alertManagerSourceName={alertManagerSourceName} defaultItem={defaultChannelValues} commonSettingsComponent={CloudCommonChannelSettings} + canEditProtectedFields={true} /> ); diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx index 8971c9b34d7..1469a3a2fe8 100644 --- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx @@ -9,7 +9,7 @@ import { } 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, @@ -137,8 +137,10 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } // 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; + const canEditProtectedFields = editMode ? hasScopedEditProtectedPermissions : true; if (isLoadingNotifiers || isLoadingOnCallIntegration) { return ( @@ -191,6 +193,7 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode } canManagePermissions={ editMode && contactPoint && showManageContactPointPermissions(GRAFANA_RULES_SOURCE_NAME, contactPoint) } + canEditProtectedFields={canEditProtectedFields} /> setTestChannelValues(undefined)} diff --git a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx index 46667f96a64..864dd3eea10 100644 --- a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx @@ -42,6 +42,7 @@ interface Props { showDefaultRouteWarning?: boolean; contactPointId?: string; canManagePermissions?: boolean; + canEditProtectedFields: boolean; } export function ReceiverForm({ @@ -58,6 +59,7 @@ export function ReceiverForm({ showDefaultRouteWarning, contactPointId, canManagePermissions, + canEditProtectedFields, }: Props) { const notifyApp = useAppNotification(); const styles = useStyles2(getStyles); @@ -66,15 +68,16 @@ export function ReceiverForm({ // 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; const formAPI = useForm>({ // making a copy here beacuse react-hook-form will mutate these, and break if the object is frozen. for real. @@ -148,6 +151,7 @@ export function ReceiverForm({ invalid={!!errors.name} error={errors.name && errors.name.message} required + noMargin > ({ onDelete={() => remove(index)} pathPrefix={pathPrefix} notifiers={notifiers} - errors={errors?.items?.[index] as FieldErrors} + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + errors={errors?.items?.[index] as FieldErrors | undefined} commonSettingsComponent={commonSettingsComponent} isEditable={isEditable} isTestable={isTestable} + canEditProtectedFields={canEditProtectedFields} customValidators={customValidators ? customValidators[field.type] : undefined} /> ); diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.test.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.test.tsx new file mode 100644 index 00000000000..c52b4bfeca5 --- /dev/null +++ b/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.test.tsx @@ -0,0 +1,448 @@ +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/features/alerting/unified/types/alerting'; + +import { OptionField } from './OptionField'; + +const TestWrapper = ({ children }: { children: React.ReactNode }) => { + const methods = useForm(); + return {children}; +}; + +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( + + + + ); +}; + +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(); + }); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx b/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx index 955d0bae209..87ee7cd2459 100644 --- a/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx +++ b/public/app/features/alerting/unified/components/receivers/form/fields/OptionField.tsx @@ -3,15 +3,19 @@ import { FC, useEffect } from 'react'; import { Controller, DeepMap, FieldError, useFormContext } from 'react-hook-form'; import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { Checkbox, Field, + Icon, Input, RadioButtonList, SecretInput, SecretTextArea, Select, + Stack, TextArea, + Tooltip, useStyles2, } from '@grafana/ui'; import { @@ -64,6 +68,7 @@ export const OptionField: FC = ({ errors={error} pathPrefix={pathPrefix} onDelete={onDeleteSubform} + getOptionMeta={getOptionMeta} /> ); } @@ -76,13 +81,34 @@ export const OptionField: FC = ({ option={option} pathPrefix={pathPrefix} errors={error as Array> | undefined} + getOptionMeta={getOptionMeta} /> ); } + const shouldShowProtectedIndicator = option.protected && getOptionMeta?.(option).readOnly; + + const labelText = option.element !== 'checkbox' && option.element !== 'radio' ? option.label : undefined; + + const label = shouldShowProtectedIndicator ? ( + + + + + {labelText} + + ) : ( + labelText + ); + return ( > | null; defaultValue?: SelectableValue; showWhen: { field: string; is: string | boolean }; diff --git a/public/app/features/alerting/unified/utils/k8s/constants.ts b/public/app/features/alerting/unified/utils/k8s/constants.ts index 1eb3c371d59..cf297261733 100644 --- a/public/app/features/alerting/unified/utils/k8s/constants.ts +++ b/public/app/features/alerting/unified/utils/k8s/constants.ts @@ -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', } /** diff --git a/public/app/features/alerting/unified/utils/k8s/utils.ts b/public/app/features/alerting/unified/utils/k8s/utils.ts index 1a99f52b522..8d699bad5b1 100644 --- a/public/app/features/alerting/unified/utils/k8s/utils.ts +++ b/public/app/features/alerting/unified/utils/k8s/utils.ts @@ -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. diff --git a/public/app/types/accessControl.ts b/public/app/types/accessControl.ts index 7ef86382edc..af68c1e1820 100644 --- a/public/app/types/accessControl.ts +++ b/public/app/types/accessControl.ts @@ -141,6 +141,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', diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4d4d2014556..f6184f67c4b 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2247,6 +2247,13 @@ "receiver-metadata-badge": { "aria-label-open-external-link": "Open external link" }, + "receivers": { + "protected": { + "field": { + "description": "This field is protected and can only be edited by users with elevated permissions" + } + } + }, "receivers-section": { "button-more": "More", "new-menu": {