[release-11.6.9] Alerting: Fix contacts point issues (#115409)
* 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 legacy configuration post API and receiver testing API to enforce permission and return status 403 if unauthorized user modifies protected field. - Update UI to disable protected fields if user cannot modify them NOTE: the legacy configuration POST API now prohibits Editor role from modifying protected fields. After creating a new integration the protected fields (mostly URLs) effectively become read-only and can be changed by Admininstrators only. 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:
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
@@ -212,7 +214,11 @@ func (srv AlertmanagerSrv) RoutePostAlertingConfig(c *contextmodel.ReqContext, b
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
}
|
||||
err = srv.mam.SaveAndApplyAlertmanagerConfiguration(c.Req.Context(), c.SignedInUser.GetOrgID(), body)
|
||||
authz := func(receiverName string, paths []models.IntegrationFieldPath) error {
|
||||
return srv.receiverAuthz.AuthorizeUpdateProtected(c.Req.Context(), c.SignedInUser, ReceiverStatus{Name: receiverName})
|
||||
}
|
||||
|
||||
err = srv.mam.SaveAndApplyAlertmanagerConfiguration(c.Req.Context(), c.SignedInUser.GetOrgID(), body, authz)
|
||||
if err == nil {
|
||||
return response.JSON(http.StatusAccepted, util.DynMap{"message": "configuration created"})
|
||||
}
|
||||
@@ -256,7 +262,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.SignedInUser.GetOrgID(), body.Receivers); err != nil {
|
||||
if err := srv.crypto.ProcessSecureSettings(c.Req.Context(), c.SignedInUser.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, "")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1359,3 +1359,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ func (moa *MultiOrgAlertmanager) gettableUserConfigFromAMConfigString(ctx contex
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (moa *MultiOrgAlertmanager) SaveAndApplyAlertmanagerConfiguration(ctx context.Context, org int64, config definitions.PostableUserConfig) error {
|
||||
func (moa *MultiOrgAlertmanager) SaveAndApplyAlertmanagerConfiguration(ctx context.Context, org int64, config definitions.PostableUserConfig, authzFn AuthorizeProtectedFn) error {
|
||||
// We cannot add this validation to PostableUserConfig as that struct is used for both
|
||||
// Grafana Alertmanager (where inhibition rules are not supported) and External Alertmanagers
|
||||
// (including Mimir) where inhibition rules are supported.
|
||||
@@ -293,7 +293,8 @@ func (moa *MultiOrgAlertmanager) SaveAndApplyAlertmanagerConfiguration(ctx conte
|
||||
}
|
||||
cleanPermissionsErr := err
|
||||
|
||||
if err := moa.Crypto.ProcessSecureSettings(ctx, org, config.AlertmanagerConfig.Receivers); err != nil {
|
||||
// TODO shoud not be nil
|
||||
if err := moa.Crypto.ProcessSecureSettings(ctx, org, config.AlertmanagerConfig.Receivers, authzFn); 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",
|
||||
@@ -1035,6 +1045,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
|
||||
Secure: true,
|
||||
Required: true,
|
||||
DependsOn: "secret",
|
||||
Protected: true,
|
||||
},
|
||||
{
|
||||
Label: "Agent ID",
|
||||
@@ -1120,6 +1131,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
|
||||
Placeholder: "http://localhost:9093",
|
||||
PropertyName: "url",
|
||||
Required: true,
|
||||
Protected: true,
|
||||
},
|
||||
{
|
||||
Label: "Basic Auth User",
|
||||
@@ -1166,6 +1178,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
|
||||
PropertyName: "url",
|
||||
Required: true,
|
||||
Secure: true,
|
||||
Protected: true,
|
||||
},
|
||||
{
|
||||
Label: "Avatar URL",
|
||||
@@ -1195,6 +1208,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
|
||||
PropertyName: "url",
|
||||
Required: true,
|
||||
Secure: true,
|
||||
Protected: true,
|
||||
},
|
||||
{
|
||||
Label: "Title",
|
||||
@@ -1315,6 +1329,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
|
||||
Description: "The URL of the MQTT broker.",
|
||||
PropertyName: "brokerUrl",
|
||||
Required: true,
|
||||
Protected: true,
|
||||
},
|
||||
{
|
||||
Label: "Topic",
|
||||
@@ -1472,6 +1487,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
|
||||
Placeholder: "https://api.opsgenie.com/v2/alerts",
|
||||
PropertyName: "apiUrl",
|
||||
Required: true,
|
||||
Protected: true,
|
||||
},
|
||||
{
|
||||
Label: "Message",
|
||||
@@ -1568,6 +1584,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",
|
||||
@@ -1602,7 +1619,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",
|
||||
@@ -1724,6 +1741,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.
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -147,4 +147,6 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
|
||||
ualert.AddAlertRuleStateTable(mg)
|
||||
|
||||
ualert.AddAlertRuleGuidMigration(mg)
|
||||
|
||||
accesscontrol.AddReceiverProtectedFieldsEditor(mg)
|
||||
}
|
||||
|
||||
@@ -552,3 +552,113 @@ func TestIntegrationAlertmanagerConfigurationPersistSecrets(t *testing.T) {
|
||||
`, generatedUID), getBody(t, resp.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAlertmanagerConfiguration_ProtectedFields(t *testing.T) {
|
||||
testinfra.SQLiteIntegrationTest(t)
|
||||
|
||||
dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
|
||||
DisableLegacyAlerting: true,
|
||||
EnableUnifiedAlerting: true,
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
DisableFeatureToggles: []string{featuremgmt.FlagAlertingApiServer},
|
||||
})
|
||||
|
||||
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path)
|
||||
|
||||
createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
|
||||
DefaultOrgRole: string(org.RoleAdmin),
|
||||
Password: "admin",
|
||||
Login: "admin",
|
||||
})
|
||||
|
||||
createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
|
||||
DefaultOrgRole: string(org.RoleEditor),
|
||||
Password: "editor",
|
||||
Login: "editor",
|
||||
})
|
||||
|
||||
adminClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
|
||||
editorClient := newAlertingApiClient(grafanaListedAddr, "editor", "editor")
|
||||
|
||||
payload := `
|
||||
{
|
||||
"template_files": {},
|
||||
"alertmanager_config": {
|
||||
"route": {
|
||||
"receiver": "webhook.receiver"
|
||||
},
|
||||
"receivers": [{
|
||||
"name": "webhook.receiver",
|
||||
"grafana_managed_receiver_configs": [{
|
||||
"settings": {
|
||||
"url": "http://localhost:9000"
|
||||
},
|
||||
"type": "webhook",
|
||||
"name": "webhook.receiver",
|
||||
"disableResolveMessage": false
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
`
|
||||
var cfg apimodels.PostableUserConfig
|
||||
err := json.Unmarshal([]byte(payload), &cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
// create a new configuration that has protected fields, one is a secret
|
||||
_, err = adminClient.PostConfiguration(t, cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
patchUIDs := func(t *testing.T) {
|
||||
t.Helper()
|
||||
gCfg, _, _ := adminClient.GetAlertmanagerConfigWithStatus(t)
|
||||
patched := 0
|
||||
for i, gr := range gCfg.AlertmanagerConfig.GetReceivers() {
|
||||
for j, gi := range gr.GrafanaManagedReceivers {
|
||||
assert.Equal(t,
|
||||
cfg.AlertmanagerConfig.Receivers[i].GrafanaManagedReceivers[j].Name,
|
||||
gi.Name,
|
||||
)
|
||||
cfg.AlertmanagerConfig.Receivers[i].GrafanaManagedReceivers[j].UID = gi.UID
|
||||
patched++
|
||||
}
|
||||
}
|
||||
}
|
||||
patchUIDs(t)
|
||||
|
||||
// Now check that editor can update non-protected fields and add new integrations to existing receivers
|
||||
cfg.AlertmanagerConfig.Receivers[0].GrafanaManagedReceivers[0].Settings = apimodels.RawMessage(`
|
||||
{
|
||||
"url":"http://localhost:9000",
|
||||
"httpMethod": "PUT"
|
||||
}
|
||||
`)
|
||||
cfg.AlertmanagerConfig.Receivers[0].GrafanaManagedReceivers = append(cfg.AlertmanagerConfig.Receivers[0].GrafanaManagedReceivers,
|
||||
&apimodels.PostableGrafanaReceiver{
|
||||
Name: cfg.AlertmanagerConfig.Receivers[0].Name,
|
||||
Type: "webhook",
|
||||
DisableResolveMessage: false,
|
||||
Settings: apimodels.RawMessage(`{"url":"http://new-localhost:9000"}`),
|
||||
SecureSettings: nil,
|
||||
},
|
||||
)
|
||||
|
||||
_, err = editorClient.PostConfiguration(t, cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
patchUIDs(t)
|
||||
|
||||
// Now editor tries to update protected field and fails
|
||||
cfg.AlertmanagerConfig.Receivers[0].GrafanaManagedReceivers[1].Settings = apimodels.RawMessage(`{"url":"http://very-localhost:9001"}`)
|
||||
|
||||
// Editor can add new integrations to existing receivers
|
||||
success, err := editorClient.PostConfiguration(t, cfg)
|
||||
require.Falsef(t, success, "the request should have failed")
|
||||
t.Log(err)
|
||||
require.Error(t, err)
|
||||
|
||||
// but Admin should still be able to update protected fields
|
||||
_, err = adminClient.PostConfiguration(t, cfg)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ func TestIntegrationResourcePermissions(t *testing.T) {
|
||||
adminClient := newClient(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()
|
||||
@@ -411,13 +411,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{})
|
||||
@@ -480,20 +481,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,
|
||||
@@ -542,22 +545,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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -615,6 +620,9 @@ func TestIntegrationAccessControl(t *testing.T) {
|
||||
if tc.canUpdate {
|
||||
expectedWithMetadata.SetAccessControl("canWrite")
|
||||
}
|
||||
if tc.canUpdateProtected {
|
||||
expectedWithMetadata.SetAccessControl("canModifyProtected")
|
||||
}
|
||||
if tc.canDelete {
|
||||
expectedWithMetadata.SetAccessControl("canDelete")
|
||||
}
|
||||
@@ -678,6 +686,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{})
|
||||
@@ -690,6 +724,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)}}
|
||||
@@ -1372,6 +1407,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.
|
||||
|
||||
@@ -50,6 +50,7 @@ beforeEach(() => {
|
||||
AccessControlAction.AlertingNotificationsWrite,
|
||||
AccessControlAction.AlertingNotificationsExternalRead,
|
||||
AccessControlAction.AlertingNotificationsExternalWrite,
|
||||
AccessControlAction.AlertingReceiversRead,
|
||||
]);
|
||||
|
||||
setupVanillaAlertmanagerServer(server);
|
||||
|
||||
@@ -27,7 +27,11 @@ const renderEditContactPoint = (contactPointUid: string) =>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite]);
|
||||
grantUserPermissions([
|
||||
AccessControlAction.AlertingNotificationsRead,
|
||||
AccessControlAction.AlertingNotificationsWrite,
|
||||
AccessControlAction.AlertingReceiversWrite,
|
||||
]);
|
||||
});
|
||||
|
||||
const getTemplatePreviewContent = async () =>
|
||||
|
||||
@@ -2,9 +2,9 @@ import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { Alert, Button, LinkButton, Stack } from '@grafana/ui';
|
||||
import { useCleanup } from 'app/core/hooks/useCleanup';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
|
||||
import { useDispatch } from 'app/types';
|
||||
import { useDispatch } from 'app/types/store';
|
||||
|
||||
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
|
||||
import { updateAlertManagerConfigAction } from '../../state/actions';
|
||||
@@ -72,7 +72,10 @@ export const GlobalConfigForm = ({ config, alertManagerSourceName }: Props) => {
|
||||
<FormProvider {...formAPI}>
|
||||
<form onSubmit={handleSubmit(onSubmitCallback)}>
|
||||
{error && (
|
||||
<Alert severity="error" title="Error saving receiver">
|
||||
<Alert
|
||||
severity="error"
|
||||
title={t('alerting.global-config-form.title-error-saving-receiver', 'Error saving receiver')}
|
||||
>
|
||||
{error.message || String(error)}
|
||||
</Alert>
|
||||
)}
|
||||
@@ -84,6 +87,7 @@ export const GlobalConfigForm = ({ config, alertManagerSourceName }: Props) => {
|
||||
option={option}
|
||||
error={errors[option.propertyName]}
|
||||
pathPrefix={''}
|
||||
secureFields={{}}
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
@@ -92,10 +96,14 @@ export const GlobalConfigForm = ({ config, alertManagerSourceName }: Props) => {
|
||||
<>
|
||||
{loading && (
|
||||
<Button disabled={true} icon="spinner" variant="primary">
|
||||
Saving...
|
||||
<Trans i18nKey="alerting.global-config-form.saving">Saving...</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{!loading && (
|
||||
<Button type="submit">
|
||||
<Trans i18nKey="alerting.global-config-form.save-global-config">Save global config</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{!loading && <Button type="submit">Save global config</Button>}
|
||||
</>
|
||||
)}
|
||||
<LinkButton
|
||||
|
||||
@@ -30,7 +30,11 @@ const renderForm = () =>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsWrite]);
|
||||
grantUserPermissions([
|
||||
AccessControlAction.AlertingNotificationsRead,
|
||||
AccessControlAction.AlertingNotificationsWrite,
|
||||
AccessControlAction.AlertingReceiversRead,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('alerting API server enabled', () => {
|
||||
@@ -44,7 +48,7 @@ describe('alerting API server enabled', () => {
|
||||
await user.type(await ui.inputs.name.find(), 'my new receiver');
|
||||
|
||||
// enter some email
|
||||
const email = ui.inputs.email.addresses.get();
|
||||
const email = await ui.inputs.email.addresses.find();
|
||||
await user.clear(email);
|
||||
await user.type(email, 'tester@grafana.com');
|
||||
|
||||
@@ -65,7 +69,7 @@ describe('alerting API server disabled', () => {
|
||||
await user.type(await ui.inputs.name.find(), 'my new receiver');
|
||||
|
||||
// enter some email
|
||||
const email = ui.inputs.email.addresses.get();
|
||||
const email = await ui.inputs.email.addresses.find();
|
||||
await user.clear(email);
|
||||
await user.type(email, 'tester@grafana.com');
|
||||
|
||||
@@ -110,7 +114,7 @@ describe('alerting API server disabled', () => {
|
||||
const { user } = renderForm();
|
||||
|
||||
await user.type(await ui.inputs.name.find(), 'receiver that should fail');
|
||||
const email = ui.inputs.email.addresses.get();
|
||||
const email = await ui.inputs.email.addresses.find();
|
||||
await user.clear(email);
|
||||
await user.type(email, 'tester@grafana.com');
|
||||
|
||||
|
||||
@@ -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, Text, useStyles2 } from '@grafana/ui';
|
||||
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,80 +44,142 @@ 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 }) => ({
|
||||
label: name,
|
||||
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}>
|
||||
{name}
|
||||
{meta?.badge}
|
||||
</Stack>
|
||||
),
|
||||
value: type,
|
||||
description: meta?.description,
|
||||
isDisabled: meta ? !meta.enabled : false,
|
||||
imgUrl: meta?.iconUrl,
|
||||
})),
|
||||
})
|
||||
),
|
||||
[notifiers]
|
||||
);
|
||||
|
||||
@@ -132,17 +200,22 @@ 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 (
|
||||
<div className={styles.wrapper} data-testid="item-container">
|
||||
<div className={styles.topRow}>
|
||||
<div>
|
||||
<Field label="Integration" htmlFor={contactPointTypeInputId} data-testid={`${pathPrefix}type`}>
|
||||
<Field
|
||||
label={t('alerting.channel-sub-form.label-integration', 'Integration')}
|
||||
htmlFor={contactPointTypeInputId}
|
||||
data-testid={`${pathPrefix}type`}
|
||||
>
|
||||
<Controller
|
||||
name={fieldName('type')}
|
||||
name={typeFieldPath}
|
||||
control={control}
|
||||
defaultValue={defaultValues.type}
|
||||
render={({ field: { ref, onChange, ...field } }) => (
|
||||
<Select
|
||||
@@ -154,28 +227,19 @@ 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'}
|
||||
>
|
||||
Test
|
||||
<Button size="xs" variant="secondary" type="button" onClick={() => handleTest()} icon="message">
|
||||
<Trans i18nKey="alerting.channel-sub-form.test">Test</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{isEditable && (
|
||||
<>
|
||||
<Button size="xs" variant="secondary" type="button" onClick={() => onDuplicate()} icon="copy">
|
||||
Duplicate
|
||||
<Trans i18nKey="alerting.channel-sub-form.duplicate">Duplicate</Trans>
|
||||
</Button>
|
||||
{onDelete && (
|
||||
<Button
|
||||
@@ -186,7 +250,7 @@ export function ChannelSubForm<R extends ChannelValues>({
|
||||
onClick={() => onDelete()}
|
||||
icon="trash-alt"
|
||||
>
|
||||
Delete
|
||||
<Trans i18nKey="alerting.channel-sub-form.delete">Delete</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@@ -212,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}
|
||||
@@ -229,17 +298,20 @@ 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>
|
||||
)}
|
||||
<CollapsibleSection label="Notification settings">
|
||||
<CollapsibleSection
|
||||
label={t('alerting.channel-sub-form.label-notification-settings', 'Notification settings')}
|
||||
>
|
||||
<CommonSettingsComponent pathPrefix={pathPrefix} readOnly={!isEditable} />
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
@@ -248,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({
|
||||
'& > * + *': {
|
||||
|
||||
@@ -88,6 +88,7 @@ export const CloudReceiverForm = ({ contactPoint, alertManagerSourceName, readOn
|
||||
alertManagerSourceName={alertManagerSourceName}
|
||||
defaultItem={defaultChannelValues}
|
||||
commonSettingsComponent={CloudCommonChannelSettings}
|
||||
canEditProtectedFields={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -55,6 +55,7 @@ describe('GrafanaReceiverForm', () => {
|
||||
grantUserPermissions([
|
||||
AccessControlAction.AlertingNotificationsRead,
|
||||
AccessControlAction.AlertingNotificationsWrite,
|
||||
AccessControlAction.AlertingReceiversRead,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,19 +2,20 @@ import { useMemo, useState } from 'react';
|
||||
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Alert, LoadingPlaceholder } from '@grafana/ui';
|
||||
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';
|
||||
@@ -135,11 +136,17 @@ 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
|
||||
// Only check k8s annotations when using k8s API
|
||||
const hasScopedEditPermissions = useK8sAPI && contactPoint ? canEditEntity(contactPoint) : true;
|
||||
const hasScopedEditProtectedPermissions = useK8sAPI && 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 <LoadingPlaceholder text="Loading notifiers..." />;
|
||||
@@ -181,6 +188,7 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
|
||||
canManagePermissions={
|
||||
editMode && contactPoint && showManageContactPointPermissions(GRAFANA_RULES_SOURCE_NAME, contactPoint)
|
||||
}
|
||||
canEditProtectedFields={canEditProtectedField}
|
||||
/>
|
||||
<TestContactPointModal
|
||||
onDismiss={() => setTestChannelValues(undefined)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { isFetchError } from '@grafana/runtime';
|
||||
import { Alert, Button, Field, Input, LinkButton, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
import { useCleanup } from 'app/core/hooks/useCleanup';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { useValidateContactPoint } from 'app/features/alerting/unified/components/contact-points/useContactPoints';
|
||||
import { ManagePermissions } from 'app/features/alerting/unified/components/permissions/ManagePermissions';
|
||||
|
||||
@@ -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.
|
||||
@@ -147,7 +150,12 @@ export function ReceiverForm<R extends ChannelValues>({
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Field label="Name" invalid={!!errors.name} error={errors.name && errors.name.message} required>
|
||||
<Field
|
||||
label={t('alerting.receiver-form.label-name', 'Name')}
|
||||
invalid={!!errors.name}
|
||||
error={errors.name && errors.name.message}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
readOnly={!isEditable}
|
||||
id="name"
|
||||
@@ -163,7 +171,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} />;
|
||||
}
|
||||
@@ -185,14 +193,16 @@ export function ReceiverForm<R extends ChannelValues>({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
integrationIndex={index}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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';
|
||||
@@ -16,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();
|
||||
@@ -38,7 +49,7 @@ export const SubformArrayField = ({ option, pathPrefix, errors, defaultValues, r
|
||||
<ActionIcon
|
||||
data-testid={`${path}.${itemIndex}.delete-button`}
|
||||
icon="trash-alt"
|
||||
tooltip="delete"
|
||||
tooltip={t('alerting.subform-array-field.tooltip-delete', 'delete')}
|
||||
onClick={() => remove(itemIndex)}
|
||||
className={styles.deleteIcon}
|
||||
/>
|
||||
@@ -46,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}
|
||||
@@ -66,7 +79,7 @@ export const SubformArrayField = ({ option, pathPrefix, errors, defaultValues, r
|
||||
size="sm"
|
||||
onClick={() => append({ __id: String(Math.random()) })}
|
||||
>
|
||||
Add
|
||||
<Trans i18nKey="alerting.subform-array-field.add">Add</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useState } from 'react';
|
||||
import { DeepMap, FieldError, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { Button, useStyles2 } from '@grafana/ui';
|
||||
import { NotificationChannelOption, NotificationChannelSecureFields } from 'app/types';
|
||||
import { Trans, t } from 'app/core/internationalization';
|
||||
import { NotificationChannelOption, NotificationChannelSecureFields, OptionMeta } from 'app/types';
|
||||
|
||||
import { ActionIcon } from '../../../rules/ActionIcon';
|
||||
|
||||
@@ -12,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;
|
||||
}
|
||||
|
||||
@@ -24,8 +31,10 @@ export const SubformField = ({
|
||||
pathPrefix,
|
||||
errors,
|
||||
defaultValue,
|
||||
getOptionMeta,
|
||||
readOnly = false,
|
||||
secureFields = {},
|
||||
secureFields,
|
||||
onDelete,
|
||||
onResetSecureField,
|
||||
}: Props) => {
|
||||
const styles = useStyles2(getReceiverFormFieldStyles);
|
||||
@@ -36,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="delete"
|
||||
onClick={() => setShow(false)}
|
||||
tooltip={t('alerting.subform-field.tooltip-delete', 'delete')}
|
||||
onClick={onDeleteClick}
|
||||
className={styles.deleteIcon}
|
||||
/>
|
||||
)}
|
||||
@@ -55,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}.`}
|
||||
@@ -78,7 +93,7 @@ export const SubformField = ({
|
||||
onClick={() => setShow(true)}
|
||||
data-testid={`${name}.add-button`}
|
||||
>
|
||||
Add
|
||||
<Trans i18nKey="alerting.subform-field.add">Add</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface NotifierMetadata {
|
||||
order: number;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
badge?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface Notifier {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface ChannelValues {
|
||||
type: string;
|
||||
settings: Record<string, any>;
|
||||
secureSettings: Record<string, any>;
|
||||
secureFields: Record<string, boolean>;
|
||||
secureFields: Record<string, boolean | ''>;
|
||||
}
|
||||
|
||||
export interface ReceiverFormValues<R extends ChannelValues> {
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,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.
|
||||
|
||||
@@ -197,10 +197,15 @@ function grafanaChannelConfigToFormChannelValues(
|
||||
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];
|
||||
// Get all secure field names (including nested ones like "sigv4.access_key")
|
||||
const secureFieldNames = notifier ? getSecureFieldNames(notifier) : [];
|
||||
|
||||
secureFieldNames.forEach((fieldPath) => {
|
||||
const value = get(values.settings, fieldPath);
|
||||
if (value !== undefined) {
|
||||
values.secureSettings[fieldPath] = value;
|
||||
// Remove from settings using omit to handle nested paths
|
||||
values.settings = omit(values.settings, [fieldPath]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ValidationRule } from 'react-hook-form';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { IconName } from '@grafana/ui';
|
||||
|
||||
@@ -141,6 +143,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 };
|
||||
@@ -221,3 +230,7 @@ export interface AnnotationItemDTO {
|
||||
avatarUrl: string;
|
||||
data: any;
|
||||
}
|
||||
export interface OptionMeta {
|
||||
required?: string | ValidationRule<boolean>;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -293,6 +293,14 @@
|
||||
"title": "Unable to display all events"
|
||||
}
|
||||
},
|
||||
"channel-sub-form": {
|
||||
"delete": "Delete",
|
||||
"duplicate": "Duplicate",
|
||||
"label-integration": "Integration",
|
||||
"label-notification-settings": "Notification settings",
|
||||
"label-section": "Optional {{name}} settings",
|
||||
"test": "Test"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"clear-filters": "Clear filters",
|
||||
@@ -368,6 +376,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"global-config-form": {
|
||||
"save-global-config": "Save global config",
|
||||
"saving": "Saving...",
|
||||
"title-error-saving-receiver": "Error saving receiver"
|
||||
},
|
||||
"group-actions": {
|
||||
"actions-trigger": "Rule group actions",
|
||||
"delete": "Delete",
|
||||
@@ -504,6 +517,16 @@
|
||||
"preview": "Preview",
|
||||
"previewCondition": "Preview alert rule condition"
|
||||
},
|
||||
"receiver-form": {
|
||||
"label-name": "Name"
|
||||
},
|
||||
"receivers": {
|
||||
"protected": {
|
||||
"field": {
|
||||
"description": "This field is protected and can only be edited by users with elevated permissions"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rule-form": {
|
||||
"evaluation": {
|
||||
"evaluation-group-and-interval": "Evaluation group and interval",
|
||||
@@ -664,6 +687,14 @@
|
||||
"simpleCondition": {
|
||||
"alertCondition": "Alert condition"
|
||||
},
|
||||
"subform-array-field": {
|
||||
"add": "Add",
|
||||
"tooltip-delete": "delete"
|
||||
},
|
||||
"subform-field": {
|
||||
"add": "Add",
|
||||
"tooltip-delete": "delete"
|
||||
},
|
||||
"templates": {
|
||||
"editor": {
|
||||
"add-example": "Add example",
|
||||
|
||||
Reference in New Issue
Block a user