Alerting: Protected fields for Contact points (#115442)
* Alerting: Protect sensitive fields of contact points from unauthorized modification - Introduce a new permission alert.notifications.receivers.protected:write. The permission is granted to contact point administrators. - Introduce field Protected to NotifierOption - Introduce DiffReport for models.Integrations with focus on Settings. The diff report is extended with methods that return all keys that are different between two settings. - Add new annotation 'grafana.com/access/CanModifyProtected' to Receiver model - Update receiver service to enforce the permission and return status 403 if unauthorized user modifies protected field - Update receiver testing API to enforce permission and return status 403 if unauthorized user modifies protected field. - Update UI to disable protected fields if user cannot modify them
This commit is contained in:
@@ -460,6 +460,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"
|
||||
|
||||
@@ -23,7 +23,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 {
|
||||
|
||||
@@ -289,12 +289,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: models.AlertRolesGroup,
|
||||
Permissions: accesscontrol.ConcatPermissions(alertingWriterRole.Role.Permissions, []accesscontrol.Permission{
|
||||
{Action: accesscontrol.ActionAlertingReceiversPermissionsRead, Scope: models.ScopeReceiversAll},
|
||||
{Action: accesscontrol.ActionAlertingReceiversPermissionsWrite, Scope: models.ScopeReceiversAll},
|
||||
{Action: accesscontrol.ActionAlertingReceiversReadSecrets, Scope: models.ScopeReceiversAll},
|
||||
{Action: accesscontrol.ActionAlertingReceiversUpdateProtected, Scope: models.ScopeReceiversAll},
|
||||
}),
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
|
||||
@@ -95,6 +95,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, models.ScopeReceiversProvider.GetResourceScopeUID(uid)),
|
||||
)
|
||||
}
|
||||
|
||||
// Asserts access to modify protected fields of all receivers.
|
||||
updateAllReceiverProtectedEval = ac.EvalAll(
|
||||
updateAllReceiversEval,
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversUpdateProtected, models.ScopeReceiversAll),
|
||||
)
|
||||
|
||||
// Delete
|
||||
|
||||
// Asserts pre-conditions for delete access to receivers. If this evaluates to false, the user cannot delete any receivers.
|
||||
@@ -141,12 +161,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
|
||||
@@ -201,6 +222,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,
|
||||
@@ -311,6 +344,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.
|
||||
@@ -380,6 +421,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))
|
||||
@@ -412,6 +459,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
|
||||
|
||||
@@ -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: models.ScopeReceiversAll},
|
||||
ac.Permission{Action: ac.ActionAlertingReceiversUpdateProtected, Scope: models.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: models.ScopeReceiversAll},
|
||||
ac.Permission{Action: ac.ActionAlertingReceiversUpdateProtected, Scope: models.ScopeReceiversProvider.GetResourceScopeUID(recv1.UID)},
|
||||
ac.Permission{Action: ac.ActionAlertingReceiversUpdate, Scope: models.ScopeReceiversProvider.GetResourceScopeUID(recv1.UID)},
|
||||
ac.Permission{Action: ac.ActionAlertingReceiversUpdate, Scope: models.ScopeReceiversProvider.GetResourceScopeUID(recv2.UID)},
|
||||
ac.Permission{Action: ac.ActionAlertingReceiversUpdateProtected, Scope: models.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",
|
||||
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"time"
|
||||
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
"github.com/grafana/alerting/receivers/schema"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
@@ -32,6 +34,7 @@ const (
|
||||
|
||||
type receiversAuthz interface {
|
||||
FilterRead(ctx context.Context, user identity.Requester, receivers ...ReceiverStatus) ([]ReceiverStatus, error)
|
||||
AuthorizeUpdateProtected(context.Context, identity.Requester, ReceiverStatus) error
|
||||
}
|
||||
|
||||
type AlertmanagerSrv struct {
|
||||
@@ -210,11 +213,16 @@ func (srv AlertmanagerSrv) RouteGetReceivers(c *contextmodel.ReqContext) respons
|
||||
}
|
||||
|
||||
func (srv AlertmanagerSrv) RoutePostTestReceivers(c *contextmodel.ReqContext, body apimodels.TestReceiversConfigBodyParams) response.Response {
|
||||
if err := srv.crypto.ProcessSecureSettings(c.Req.Context(), c.GetOrgID(), body.Receivers); err != nil {
|
||||
if err := srv.crypto.ProcessSecureSettings(c.Req.Context(), c.GetOrgID(), body.Receivers, func(receiverName string, paths []schema.IntegrationFieldPath) error {
|
||||
return srv.receiverAuthz.AuthorizeUpdateProtected(c.Req.Context(), c.SignedInUser, ReceiverStatus{Name: receiverName})
|
||||
}); err != nil {
|
||||
var unknownReceiverError UnknownReceiverError
|
||||
if errors.As(err, &unknownReceiverError) {
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
if errors.As(err, &errutil.Error{}) {
|
||||
return response.Err(err)
|
||||
}
|
||||
return ErrResp(http.StatusInternalServerError, err, "failed to post process Alertmanager configuration")
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
type ReceiverPermission string
|
||||
|
||||
const (
|
||||
ReceiverPermissionReadSecret ReceiverPermission = "secrets"
|
||||
ReceiverPermissionAdmin ReceiverPermission = "admin"
|
||||
ReceiverPermissionWrite ReceiverPermission = "write"
|
||||
ReceiverPermissionDelete ReceiverPermission = "delete"
|
||||
ReceiverPermissionReadSecret ReceiverPermission = "secrets"
|
||||
ReceiverPermissionAdmin ReceiverPermission = "admin"
|
||||
ReceiverPermissionWrite ReceiverPermission = "write"
|
||||
ReceiverPermissionDelete ReceiverPermission = "delete"
|
||||
ReceiverPermissionModifyProtected ReceiverPermission = "modify-protected"
|
||||
)
|
||||
|
||||
// ReceiverPermissions returns all possible silence permissions.
|
||||
@@ -22,6 +23,7 @@ func ReceiverPermissions() []ReceiverPermission {
|
||||
ReceiverPermissionAdmin,
|
||||
ReceiverPermissionWrite,
|
||||
ReceiverPermissionDelete,
|
||||
ReceiverPermissionModifyProtected,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/grafana/alerting/receivers/schema"
|
||||
|
||||
"github.com/grafana/grafana/pkg/util/cmputil"
|
||||
)
|
||||
|
||||
type IntegrationDiffReport struct {
|
||||
cmputil.DiffReport
|
||||
}
|
||||
|
||||
// expandPaths recursively collects all sub-paths for keys in the provided map value
|
||||
func (r IntegrationDiffReport) expandPaths(basePath schema.IntegrationFieldPath, mapVal reflect.Value) []schema.IntegrationFieldPath {
|
||||
result := make([]schema.IntegrationFieldPath, 0)
|
||||
iter := mapVal.MapRange()
|
||||
for iter.Next() {
|
||||
keyStr := fmt.Sprintf("%v", iter.Key()) // Assume string keys
|
||||
p := basePath.With(keyStr)
|
||||
// Recurse if the sub-value is another map
|
||||
if m, ok := r.getMap(iter.Value()); ok {
|
||||
result = append(result, r.expandPaths(p, m)...)
|
||||
continue
|
||||
}
|
||||
result = append(result, p)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r IntegrationDiffReport) getMap(v reflect.Value) (reflect.Value, bool) {
|
||||
if v.Kind() == reflect.Map {
|
||||
return v, true
|
||||
}
|
||||
if v.Kind() == reflect.Ptr || v.Kind() == reflect.Interface {
|
||||
return r.getMap(v.Elem())
|
||||
}
|
||||
return reflect.Value{}, false
|
||||
}
|
||||
|
||||
func (r IntegrationDiffReport) needExpand(diff cmputil.Diff) (reflect.Value, bool) {
|
||||
ml, lok := r.getMap(diff.Left)
|
||||
mr, rok := r.getMap(diff.Right)
|
||||
if lok == rok {
|
||||
return reflect.Value{}, false
|
||||
}
|
||||
if lok {
|
||||
return ml, true
|
||||
}
|
||||
return mr, true
|
||||
}
|
||||
|
||||
func (r IntegrationDiffReport) GetSettingsPaths() []schema.IntegrationFieldPath {
|
||||
diffs := r.GetDiffsForField("Settings")
|
||||
paths := make([]schema.IntegrationFieldPath, 0, len(diffs))
|
||||
for _, diff := range diffs {
|
||||
// diff.Path has format like Settings[url] or Settings[sub-form][field]
|
||||
p := diff.Path
|
||||
var path schema.IntegrationFieldPath
|
||||
for {
|
||||
start := strings.Index(p, "[")
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
p = p[start+1:]
|
||||
end := strings.Index(p, "]")
|
||||
if end == -1 {
|
||||
break
|
||||
}
|
||||
fieldName := p[:end]
|
||||
p = p[end+1:]
|
||||
path = append(path, fieldName)
|
||||
}
|
||||
if m, ok := r.needExpand(diff); ok {
|
||||
paths = append(paths, r.expandPaths(path, m)...)
|
||||
continue
|
||||
}
|
||||
if len(path) > 0 {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func (r IntegrationDiffReport) GetSecureSettingsPaths() []schema.IntegrationFieldPath {
|
||||
diffs := r.GetDiffsForField("SecureSettings")
|
||||
paths := make([]schema.IntegrationFieldPath, 0, len(diffs))
|
||||
for _, diff := range diffs {
|
||||
if diff.Path == "SecureSettings" {
|
||||
if m, ok := r.needExpand(diff); ok {
|
||||
paths = append(paths, r.expandPaths(nil, m)...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// diff.Path has format like SecureSettings[field.sub-field.sub]
|
||||
p := schema.ParseIntegrationPath(diff.Path[len("SecureSettings[") : len(diff.Path)-1])
|
||||
paths = append(paths, p)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func (integration *Integration) Diff(incoming Integration) IntegrationDiffReport {
|
||||
var reporter cmputil.DiffReporter
|
||||
var settingsCmp = cmpopts.AcyclicTransformer("settingsMap", func(in map[string]any) map[string]any {
|
||||
if in == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return in
|
||||
})
|
||||
var secureCmp = cmpopts.AcyclicTransformer("secureMap", func(in map[string]string) map[string]string {
|
||||
if in == nil {
|
||||
return map[string]string{}
|
||||
}
|
||||
return in
|
||||
})
|
||||
schemaCmp := cmp.Comparer(func(a, b schema.IntegrationSchemaVersion) bool {
|
||||
isAZero := reflect.ValueOf(a).IsZero()
|
||||
isBZero := reflect.ValueOf(b).IsZero()
|
||||
if isAZero && isBZero {
|
||||
return true
|
||||
}
|
||||
if isAZero || isBZero {
|
||||
return false
|
||||
}
|
||||
return a.Type() == b.Type() && a.Version == b.Version
|
||||
})
|
||||
var cur Integration
|
||||
if integration != nil {
|
||||
cur = *integration
|
||||
}
|
||||
cmp.Equal(cur, incoming, cmp.Reporter(&reporter), settingsCmp, secureCmp, schemaCmp)
|
||||
return IntegrationDiffReport{DiffReport: reporter.Diffs}
|
||||
}
|
||||
|
||||
// HasReceiversDifferentProtectedFields returns true if the receiver has any protected fields that are different from the incoming receiver.
|
||||
func HasReceiversDifferentProtectedFields(existing, incoming *Receiver) map[string][]schema.IntegrationFieldPath {
|
||||
existingIntegrations := make(map[string]*Integration, len(existing.Integrations))
|
||||
for _, integration := range existing.Integrations {
|
||||
existingIntegrations[integration.UID] = integration
|
||||
}
|
||||
|
||||
var result = make(map[string][]schema.IntegrationFieldPath)
|
||||
for _, in := range incoming.Integrations {
|
||||
if in.UID == "" {
|
||||
continue
|
||||
}
|
||||
ex, ok := existingIntegrations[in.UID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
paths := HasIntegrationsDifferentProtectedFields(ex, in)
|
||||
if len(paths) > 0 {
|
||||
result[in.UID] = paths
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// HasIntegrationsDifferentProtectedFields returns list of paths to protected fields that are different between two integrations.
|
||||
func HasIntegrationsDifferentProtectedFields(existing, incoming *Integration) []schema.IntegrationFieldPath {
|
||||
diff := existing.Diff(*incoming)
|
||||
// The incoming receiver always has both secret and non-secret fields in Settings.
|
||||
// So, if it's specified and happens to be sensitive, we consider it changed
|
||||
var result []schema.IntegrationFieldPath
|
||||
settingsDiff := diff.GetSettingsPaths()
|
||||
for _, path := range settingsDiff {
|
||||
if IsProtectedField(incoming.Config.Type(), path) {
|
||||
result = append(result, path)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// IsProtectedField returns true if the field at the given path is existing protected one.
|
||||
// This includes:
|
||||
// 1. URL fields marked as secure in the schema (e.g., webhook URLs with credentials)
|
||||
// 2. URL fields NOT marked as secure but could contain credentials (e.g., API endpoints)
|
||||
func IsProtectedField(integrationType schema.IntegrationType, path schema.IntegrationFieldPath) bool {
|
||||
str := strings.ToLower(string(integrationType))
|
||||
pathStr := path.String()
|
||||
|
||||
switch str {
|
||||
case "prometheus-alertmanager":
|
||||
return pathStr == "url"
|
||||
case "dingding":
|
||||
return pathStr == "url" // marked as secure
|
||||
case "discord":
|
||||
return pathStr == "url" // marked as secure (webhook URL)
|
||||
case "googlechat":
|
||||
return pathStr == "url" // marked as secure
|
||||
case "jira":
|
||||
return pathStr == "api_url"
|
||||
case "kafka":
|
||||
return pathStr == "kafkaRestProxy"
|
||||
case "line":
|
||||
return false
|
||||
case "mqtt":
|
||||
return pathStr == "brokerUrl"
|
||||
case "oncall":
|
||||
return pathStr == "url"
|
||||
case "opsgenie":
|
||||
return pathStr == "apiUrl"
|
||||
case "pagerduty":
|
||||
return pathStr == "url"
|
||||
case "sensugo":
|
||||
return pathStr == "url"
|
||||
case "slack":
|
||||
return pathStr == "url" || pathStr == "endpointUrl"
|
||||
case "teams":
|
||||
return pathStr == "url"
|
||||
case "victorops":
|
||||
return pathStr == "url" // marked as secure
|
||||
case "webex":
|
||||
return pathStr == "api_url"
|
||||
case "webhook":
|
||||
return pathStr == "url" ||
|
||||
pathStr == "http_config.oauth2.token_url" ||
|
||||
pathStr == "http_config.oauth2.proxy_config.proxy_url"
|
||||
case "wecom":
|
||||
return pathStr == "url" || // marked as secure
|
||||
pathStr == "endpointUrl"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
"github.com/grafana/alerting/receivers/schema"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIntegrationDiff(t *testing.T) {
|
||||
s, _ := alertingNotify.GetSchemaVersionForIntegration("webhook", schema.V1)
|
||||
a := Integration{
|
||||
UID: "test-uid",
|
||||
Name: "test-name",
|
||||
Config: s,
|
||||
DisableResolveMessage: false,
|
||||
Settings: map[string]any{
|
||||
"url": "http://localhost",
|
||||
"name": 123,
|
||||
"flag": true,
|
||||
"child": map[string]any{
|
||||
"sub-form-field": "test",
|
||||
},
|
||||
},
|
||||
SecureSettings: map[string]string{
|
||||
"password": "12345",
|
||||
"token": "token-12345",
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("no diff if equal", func(t *testing.T) {
|
||||
result := a.Diff(a)
|
||||
assert.Empty(t, result)
|
||||
})
|
||||
|
||||
t.Run("should deep compare settings", func(t *testing.T) {
|
||||
b := a
|
||||
b.Settings = map[string]any{
|
||||
"url": "http://localhost:123",
|
||||
"flag": false,
|
||||
"child": map[string]any{
|
||||
"sub-form-field": "test123",
|
||||
"sub-child": map[string]any{
|
||||
"test": "test",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := a.Diff(b)
|
||||
assert.ElementsMatch(t,
|
||||
[]string{"Settings[url]", "Settings[name]", "Settings[flag]", "Settings[child][sub-form-field]", "Settings[child][sub-child]"},
|
||||
result.Paths())
|
||||
})
|
||||
|
||||
t.Run("should shallow compare schemas", func(t *testing.T) {
|
||||
b := a
|
||||
b.Config, _ = alertingNotify.GetSchemaVersionForIntegration("slack", schema.V1)
|
||||
result := a.Diff(b)
|
||||
assert.ElementsMatch(t,
|
||||
[]string{"Config"},
|
||||
result.Paths())
|
||||
})
|
||||
|
||||
t.Run("should compare with zero objects", func(t *testing.T) {
|
||||
result := a.Diff(Integration{})
|
||||
assert.ElementsMatch(t,
|
||||
[]string{
|
||||
"UID",
|
||||
"Name",
|
||||
"Config",
|
||||
"Settings[child]",
|
||||
"Settings[flag]",
|
||||
"Settings[name]",
|
||||
"Settings[url]",
|
||||
"SecureSettings[password]",
|
||||
"SecureSettings[token]",
|
||||
},
|
||||
result.Paths())
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationDiffReport_GetSettingsPaths(t *testing.T) {
|
||||
a := Integration{
|
||||
UID: "test-uid",
|
||||
Name: "test-name",
|
||||
Config: schema.IntegrationSchemaVersion{},
|
||||
DisableResolveMessage: false,
|
||||
Settings: map[string]any{
|
||||
"url": "http://localhost",
|
||||
"child": map[string]any{
|
||||
"field": "test",
|
||||
"sub-child": map[string]any{
|
||||
"test": "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
left map[string]any
|
||||
right map[string]any
|
||||
paths []string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
left: map[string]any{},
|
||||
right: map[string]any{},
|
||||
},
|
||||
{
|
||||
name: "left is empty",
|
||||
left: map[string]any{},
|
||||
right: map[string]any{
|
||||
"field": "test",
|
||||
},
|
||||
paths: []string{"field"},
|
||||
},
|
||||
{
|
||||
name: "right is empty",
|
||||
left: map[string]any{
|
||||
"field": "test",
|
||||
},
|
||||
right: map[string]any{},
|
||||
paths: []string{"field"},
|
||||
},
|
||||
{
|
||||
name: "expands nested",
|
||||
left: map[string]any{
|
||||
"field": map[string]any{
|
||||
"sub-field": map[string]any{
|
||||
"test": "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
right: map[string]any{
|
||||
"another": map[string]any{
|
||||
"sub-field": map[string]any{
|
||||
"test": "test",
|
||||
},
|
||||
},
|
||||
},
|
||||
paths: []string{
|
||||
"field.sub-field.test",
|
||||
"another.sub-field.test",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
b := a
|
||||
b.Settings = tc.right
|
||||
a.Settings = tc.left
|
||||
diff := a.Diff(b)
|
||||
|
||||
actual := diff.GetSettingsPaths()
|
||||
actualStrings := make([]string, 0, len(actual))
|
||||
for _, f := range actual {
|
||||
actualStrings = append(actualStrings, f.String())
|
||||
}
|
||||
assert.ElementsMatch(t, tc.paths, actualStrings)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasDifferentProtectedFields(t *testing.T) {
|
||||
m := IntegrationMuts
|
||||
|
||||
testCase := []struct {
|
||||
name string
|
||||
existing Integration
|
||||
incoming Integration
|
||||
expected map[string][]string
|
||||
}{
|
||||
{
|
||||
name: "different UID do not match",
|
||||
existing: IntegrationGen(m.WithUID("existing"), m.WithValidConfig("webhook"))(),
|
||||
incoming: IntegrationGen(
|
||||
m.WithValidConfig("webhook"),
|
||||
m.AddSetting("url", "http://some-other-url"),
|
||||
m.WithUID("incoming"),
|
||||
)(),
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "find url protected",
|
||||
existing: IntegrationGen(m.WithUID("1"), m.WithValidConfig("webhook"))(),
|
||||
incoming: IntegrationGen(
|
||||
m.WithValidConfig("webhook"),
|
||||
m.AddSetting("url", "http://some-other-url"),
|
||||
m.AddSetting("http_config", map[string]any{
|
||||
"oauth2": map[string]any{
|
||||
"proxy_config": map[string]any{
|
||||
"proxy_url": "http://some-other-url-proxy",
|
||||
},
|
||||
"token_url": "http://some-other-url-token",
|
||||
},
|
||||
}),
|
||||
m.WithUID("1"),
|
||||
)(),
|
||||
expected: map[string][]string{
|
||||
"1": {
|
||||
"http_config.oauth2.proxy_config.proxy_url",
|
||||
"http_config.oauth2.token_url",
|
||||
"url",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "secure and protected", // simulate the situation when protected secured field is in secure settings but the incoming one has it in settings
|
||||
existing: IntegrationGen(
|
||||
m.WithUID("1"),
|
||||
m.WithValidConfig("discord"),
|
||||
m.RemoveSetting("url"),
|
||||
m.WithSecureSettings(map[string]string{
|
||||
"url": "<SECURED>",
|
||||
}))(),
|
||||
incoming: IntegrationGen(
|
||||
m.WithValidConfig("discord"),
|
||||
m.AddSetting("url", "http://some-other-url"),
|
||||
m.WithSecureSettings(nil),
|
||||
m.WithUID("1"),
|
||||
)(),
|
||||
expected: map[string][]string{
|
||||
"1": {
|
||||
"url",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCase {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
existing := &Receiver{
|
||||
Integrations: []*Integration{
|
||||
&tc.existing,
|
||||
},
|
||||
}
|
||||
incoming := &Receiver{
|
||||
Integrations: []*Integration{
|
||||
&tc.incoming,
|
||||
},
|
||||
}
|
||||
actual := HasReceiversDifferentProtectedFields(existing, incoming)
|
||||
if len(tc.expected) == 0 {
|
||||
require.Empty(t, actual)
|
||||
return
|
||||
}
|
||||
actualStrings := make(map[string][]string, len(actual))
|
||||
for uid, paths := range actual {
|
||||
for _, path := range paths {
|
||||
actualStrings[uid] = append(actualStrings[uid], path.String())
|
||||
}
|
||||
slices.Sort(actualStrings[uid])
|
||||
}
|
||||
assert.EqualValues(t, tc.expected, actualStrings)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1457,6 +1457,12 @@ func (n IntegrationMutators) AddSecureSetting(key, val string) Mutator[Integrati
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) RemoveSetting(key string) Mutator[Integration] {
|
||||
return func(c *Integration) {
|
||||
delete(c.Settings, key)
|
||||
}
|
||||
}
|
||||
|
||||
func randomMapKey[K comparable, V any](m map[K]V) (K, V) {
|
||||
randIdx := rand.Intn(len(m))
|
||||
i := 0
|
||||
|
||||
@@ -333,7 +333,7 @@ func (moa *MultiOrgAlertmanager) SaveAndApplyAlertmanagerConfiguration(ctx conte
|
||||
config.ExtraConfigs = extraConfigs
|
||||
}
|
||||
|
||||
if err := moa.Crypto.ProcessSecureSettings(ctx, org, config.AlertmanagerConfig.Receivers); err != nil {
|
||||
if err := moa.Crypto.ProcessSecureSettings(ctx, org, config.AlertmanagerConfig.Receivers, nil); err != nil {
|
||||
return fmt.Errorf("failed to post process Alertmanager configuration: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,16 +29,18 @@ const (
|
||||
cryptoPrefix = "crypto_"
|
||||
)
|
||||
|
||||
type AuthorizeProtectedFn func(uid string, paths []schema.IntegrationFieldPath) error
|
||||
|
||||
// Crypto allows decryption of Alertmanager Configuration and encryption of arbitrary payloads.
|
||||
type Crypto interface {
|
||||
LoadSecureSettings(ctx context.Context, orgId int64, receivers []*definitions.PostableApiReceiver) error
|
||||
LoadSecureSettings(ctx context.Context, orgId int64, receivers []*definitions.PostableApiReceiver, fn AuthorizeProtectedFn) error
|
||||
Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error)
|
||||
Decrypt(ctx context.Context, payload []byte) ([]byte, error)
|
||||
EncryptExtraConfigs(ctx context.Context, config *definitions.PostableUserConfig) error
|
||||
DecryptExtraConfigs(ctx context.Context, config *definitions.PostableUserConfig) error
|
||||
|
||||
getDecryptedSecret(r *definitions.PostableGrafanaReceiver, key string) (string, error)
|
||||
ProcessSecureSettings(ctx context.Context, orgId int64, recvs []*definitions.PostableApiReceiver) error
|
||||
ProcessSecureSettings(ctx context.Context, orgId int64, recvs []*definitions.PostableApiReceiver, fn AuthorizeProtectedFn) error
|
||||
}
|
||||
|
||||
// alertmanagerCrypto implements decryption of Alertmanager configuration and encryption of arbitrary payloads based on Grafana's encryptions.
|
||||
@@ -57,7 +59,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
|
||||
@@ -68,7 +70,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
|
||||
}
|
||||
|
||||
@@ -167,7 +169,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 {
|
||||
@@ -176,10 +178,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...")
|
||||
@@ -209,6 +211,33 @@ func (c *alertmanagerCrypto) LoadSecureSettings(ctx context.Context, orgId int64
|
||||
return UnknownReceiverError{UID: gr.UID}
|
||||
}
|
||||
|
||||
if authorizeProtected != nil {
|
||||
incoming, errIn := legacy_storage.PostableGrafanaReceiverToIntegration(gr)
|
||||
existing, errEx := legacy_storage.PostableGrafanaReceiverToIntegration(cgmr)
|
||||
var secure []schema.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 {
|
||||
|
||||
@@ -80,6 +80,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)
|
||||
}
|
||||
|
||||
@@ -474,6 +477,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
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"slices"
|
||||
|
||||
"github.com/grafana/alerting/receivers/schema"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
)
|
||||
|
||||
func makeProtectedFieldsAuthzError(err error, diff map[string][]schema.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
|
||||
}
|
||||
@@ -659,8 +659,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: {models.ScopeReceiversAll},
|
||||
},
|
||||
}}
|
||||
decryptUser := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{
|
||||
@@ -1310,7 +1311,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()))()
|
||||
@@ -1322,8 +1323,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",
|
||||
@@ -1411,6 +1412,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: {models.ScopeReceiversAll},
|
||||
accesscontrol.ActionAlertingReceiversRead: {models.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: {models.ScopeReceiversAll},
|
||||
accesscontrol.ActionAlertingReceiversRead: {models.ScopeReceiversAll},
|
||||
accesscontrol.ActionAlertingReceiversUpdateProtected: {models.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 {
|
||||
@@ -1436,7 +1474,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)
|
||||
@@ -1734,6 +1776,7 @@ func TestReceiverService_AccessControlMetadata(t *testing.T) {
|
||||
expectedPermissions.Set(models.ReceiverPermissionAdmin, false)
|
||||
expectedPermissions.Set(models.ReceiverPermissionWrite, false)
|
||||
expectedPermissions.Set(models.ReceiverPermissionDelete, false)
|
||||
expectedPermissions.Set(models.ReceiverPermissionModifyProtected, false)
|
||||
expectedPermissions.Set(models.ReceiverPermissionReadSecret, true)
|
||||
|
||||
expected := map[string]models.ReceiverPermissionSet{
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
|
||||
@@ -168,4 +168,6 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
|
||||
ualert.CollateBinAlertRuleNamespace(mg)
|
||||
|
||||
ualert.CollateBinAlertRuleGroup(mg)
|
||||
|
||||
accesscontrol.AddReceiverProtectedFieldsEditor(mg)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user