diff --git a/pkg/apis/alerting_notifications/v0alpha1/types_ext.go b/pkg/apis/alerting_notifications/v0alpha1/types_ext.go index df9079bf675..f40e13668da 100644 --- a/pkg/apis/alerting_notifications/v0alpha1/types_ext.go +++ b/pkg/apis/alerting_notifications/v0alpha1/types_ext.go @@ -1,6 +1,9 @@ package v0alpha1 -const ProvenanceStatusAnnotationKey = "grafana.com/provenance" +import "fmt" + +const InternalPrefix = "grafana.com/" +const ProvenanceStatusAnnotationKey = InternalPrefix + "provenance" const ProvenanceStatusNone = "none" func (o *TimeInterval) GetProvenanceStatus() string { @@ -44,3 +47,16 @@ func (o *Receiver) SetProvenanceStatus(status string) { } o.Annotations[ProvenanceStatusAnnotationKey] = status } + +func (o *Receiver) SetAccessControl(action string) { + if o.Annotations == nil { + o.Annotations = make(map[string]string, 1) + } + o.Annotations[AccessControlAnnotation(action)] = "true" +} + +// AccessControlAnnotation returns the key for the access control annotation for the given action. +// Ex. grafana.com/access/canDelete. +func AccessControlAnnotation(action string) string { + return fmt.Sprintf("%s%s/%s", InternalPrefix, "access", action) +} diff --git a/pkg/registry/apis/alerting/notifications/receiver/conversions.go b/pkg/registry/apis/alerting/notifications/receiver/conversions.go index 5b273ff7c2a..f6d71779550 100644 --- a/pkg/registry/apis/alerting/notifications/receiver/conversions.go +++ b/pkg/registry/apis/alerting/notifications/receiver/conversions.go @@ -1,6 +1,7 @@ package receiver import ( + "fmt" "maps" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -14,12 +15,18 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" ) -func convertToK8sResources(orgID int64, receivers []*ngmodels.Receiver, namespacer request.NamespaceMapper, selector fields.Selector) (*model.ReceiverList, error) { +func convertToK8sResources(orgID int64, receivers []*ngmodels.Receiver, accesses map[string]ngmodels.ReceiverPermissionSet, namespacer request.NamespaceMapper, selector fields.Selector) (*model.ReceiverList, error) { result := &model.ReceiverList{ Items: make([]model.Receiver, 0, len(receivers)), } for _, receiver := range receivers { - k8sResource, err := convertToK8sResource(orgID, receiver, namespacer) + var access *ngmodels.ReceiverPermissionSet + if accesses != nil { + if a, ok := accesses[receiver.GetUID()]; ok { + access = &a + } + } + k8sResource, err := convertToK8sResource(orgID, receiver, access, namespacer) if err != nil { return nil, err } @@ -31,7 +38,7 @@ func convertToK8sResources(orgID int64, receivers []*ngmodels.Receiver, namespac return result, nil } -func convertToK8sResource(orgID int64, receiver *ngmodels.Receiver, namespacer request.NamespaceMapper) (*model.Receiver, error) { +func convertToK8sResource(orgID int64, receiver *ngmodels.Receiver, access *ngmodels.ReceiverPermissionSet, namespacer request.NamespaceMapper) (*model.Receiver, error) { spec := model.ReceiverSpec{ Title: receiver.Name, } @@ -56,9 +63,29 @@ func convertToK8sResource(orgID int64, receiver *ngmodels.Receiver, namespacer r Spec: spec, } r.SetProvenanceStatus(string(receiver.Provenance)) + + if access != nil { + for _, action := range ngmodels.ReceiverPermissions() { + mappedAction, ok := permissionMapper[action] + if !ok { + return nil, fmt.Errorf("unknown action %v", action) + } + if can, _ := access.Has(action); can { + r.SetAccessControl(mappedAction) + } + } + } + return r, nil } +var permissionMapper = map[ngmodels.ReceiverPermission]string{ + ngmodels.ReceiverPermissionReadSecret: "canReadSecrets", + //ngmodels.ReceiverPermissionAdmin: "canAdmin", // TODO: Add when resource permissions are implemented. + ngmodels.ReceiverPermissionWrite: "canWrite", + ngmodels.ReceiverPermissionDelete: "canDelete", +} + func convertToDomainModel(receiver *model.Receiver) (*ngmodels.Receiver, map[string][]string, error) { domain := &ngmodels.Receiver{ UID: legacy_storage.NameToUid(receiver.Spec.Title), diff --git a/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go b/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go index 6d5b22f7b51..d859d695bbf 100644 --- a/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go +++ b/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go @@ -33,10 +33,15 @@ type ReceiverService interface { DeleteReceiver(ctx context.Context, name string, provenance definitions.Provenance, version string, orgID int64, user identity.Requester) error } +type MetadataService interface { + Access(ctx context.Context, user identity.Requester, receivers ...*ngmodels.Receiver) (map[string]ngmodels.ReceiverPermissionSet, error) +} + type legacyStorage struct { service ReceiverService namespacer request.NamespaceMapper tableConverter rest.TableConvertor + metadata MetadataService } func (s *legacyStorage) New() runtime.Object { @@ -85,7 +90,12 @@ func (s *legacyStorage) List(ctx context.Context, opts *internalversion.ListOpti return nil, err } - return convertToK8sResources(orgId, res, s.namespacer, opts.FieldSelector) + accesses, err := s.metadata.Access(ctx, user, res...) + if err != nil { + return nil, fmt.Errorf("failed to get access control metadata: %w", err) + } + + return convertToK8sResources(orgId, res, accesses, s.namespacer, opts.FieldSelector) } func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOptions) (runtime.Object, error) { @@ -113,7 +123,18 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption if err != nil { return nil, err } - return convertToK8sResource(info.OrgID, r, s.namespacer) + + var access *ngmodels.ReceiverPermissionSet + accesses, err := s.metadata.Access(ctx, user, r) + if err == nil { + if a, ok := accesses[r.GetUID()]; ok { + access = &a + } + } else { + return nil, fmt.Errorf("failed to get access control metadata: %w", err) + } + + return convertToK8sResource(info.OrgID, r, access, s.namespacer) } func (s *legacyStorage) Create(ctx context.Context, @@ -151,7 +172,7 @@ func (s *legacyStorage) Create(ctx context.Context, if err != nil { return nil, err } - return convertToK8sResource(info.OrgID, out, s.namespacer) + return convertToK8sResource(info.OrgID, out, nil, s.namespacer) } func (s *legacyStorage) Update(ctx context.Context, @@ -203,7 +224,7 @@ func (s *legacyStorage) Update(ctx context.Context, return nil, false, err } - r, err := convertToK8sResource(info.OrgID, updated, s.namespacer) + r, err := convertToK8sResource(info.OrgID, updated, nil, s.namespacer) return r, false, err } diff --git a/pkg/registry/apis/alerting/notifications/receiver/storage.go b/pkg/registry/apis/alerting/notifications/receiver/storage.go index db2db4a7bbe..5a2f5804e9b 100644 --- a/pkg/registry/apis/alerting/notifications/receiver/storage.go +++ b/pkg/registry/apis/alerting/notifications/receiver/storage.go @@ -34,11 +34,13 @@ func NewStorage( scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, dualWriteBuilder grafanarest.DualWriteBuilder, + metadata MetadataService, ) (rest.Storage, error) { legacyStore := &legacyStorage{ service: legacySvc, namespacer: namespacer, tableConverter: resourceInfo.TableConverter(), + metadata: metadata, } if optsGetter != nil && dualWriteBuilder != nil { strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion()) diff --git a/pkg/registry/apis/alerting/notifications/register.go b/pkg/registry/apis/alerting/notifications/register.go index aa588ff9a53..641fd15c5dc 100644 --- a/pkg/registry/apis/alerting/notifications/register.go +++ b/pkg/registry/apis/alerting/notifications/register.go @@ -85,7 +85,7 @@ func (t *NotificationsAPIBuilder) GetAPIGroupInfo( return nil, fmt.Errorf("failed to initialize time-interval storage: %w", err) } - recvStorage, err := receiver.NewStorage(t.ng.Api.ReceiverService, t.namespacer, scheme, optsGetter, dualWriteBuilder) + recvStorage, err := receiver.NewStorage(t.ng.Api.ReceiverService, t.namespacer, scheme, optsGetter, dualWriteBuilder, ac.NewReceiverAccess[*ngmodels.Receiver](t.ng.Api.AccessControl, false)) if err != nil { return nil, fmt.Errorf("failed to initialize receiver storage: %w", err) } diff --git a/pkg/services/ngalert/accesscontrol/receivers.go b/pkg/services/ngalert/accesscontrol/receivers.go index 8629cf55a09..46d186705ee 100644 --- a/pkg/services/ngalert/accesscontrol/receivers.go +++ b/pkg/services/ngalert/accesscontrol/receivers.go @@ -132,35 +132,12 @@ type ReceiverAccess[T models.Identified] struct { readDecrypted actionAccess[T] create actionAccess[T] update actionAccess[T] - delete actionAccess[models.Identified] + delete actionAccess[T] } // NewReceiverAccess creates a new ReceiverAccess service. If includeProvisioningActions is true, the service will include // permissions specific to the provisioning API. func NewReceiverAccess[T models.Identified](a ac.AccessControl, includeProvisioningActions bool) *ReceiverAccess[T] { - // If this service is meant for the provisioning API, we include the provisioning actions as possible permissions. - // TODO: Improve this monkey patching. - readRedactedReceiversPreConditionsEval := readRedactedReceiversPreConditionsEval - readDecryptedReceiversPreConditionsEval := readDecryptedReceiversPreConditionsEval - readRedactedReceiverEval := readRedactedReceiverEval - readDecryptedReceiverEval := readDecryptedReceiverEval - readRedactedAllReceiversEval := readRedactedAllReceiversEval - readDecryptedAllReceiversEval := readDecryptedAllReceiversEval - if includeProvisioningActions { - readRedactedReceiversPreConditionsEval = ac.EvalAny(provisioningExtraReadRedactedPermissions, readRedactedReceiversPreConditionsEval) - readDecryptedReceiversPreConditionsEval = ac.EvalAny(provisioningExtraReadDecryptedPermissions, readDecryptedReceiversPreConditionsEval) - - readRedactedReceiverEval = func(uid string) ac.Evaluator { - return ac.EvalAny(provisioningExtraReadRedactedPermissions, readRedactedReceiverEval(uid)) - } - readDecryptedReceiverEval = func(uid string) ac.Evaluator { - return ac.EvalAny(provisioningExtraReadDecryptedPermissions, readDecryptedReceiverEval(uid)) - } - - readRedactedAllReceiversEval = ac.EvalAny(provisioningExtraReadRedactedPermissions, readRedactedAllReceiversEval) - readDecryptedAllReceiversEval = ac.EvalAny(provisioningExtraReadDecryptedPermissions, readDecryptedAllReceiversEval) - } - rcvAccess := &ReceiverAccess[T]{ read: actionAccess[T]{ genericService: genericService{ @@ -192,11 +169,11 @@ func NewReceiverAccess[T models.Identified](a ac.AccessControl, includeProvision }, resource: "receiver", action: "create", - authorizeSome: ac.EvalAll(readRedactedReceiversPreConditionsEval, createReceiversEval), + authorizeSome: createReceiversEval, authorizeOne: func(receiver models.Identified) ac.Evaluator { - return ac.EvalAll(readRedactedReceiversPreConditionsEval, createReceiversEval) + return createReceiversEval }, - authorizeAll: ac.EvalAll(readRedactedReceiversPreConditionsEval, createReceiversEval), + authorizeAll: createReceiversEval, }, update: actionAccess[T]{ genericService: genericService{ @@ -204,29 +181,67 @@ func NewReceiverAccess[T models.Identified](a ac.AccessControl, includeProvision }, resource: "receiver", action: "update", - authorizeSome: ac.EvalAll(readRedactedReceiversPreConditionsEval, updateReceiversPreConditionsEval), + authorizeSome: updateReceiversPreConditionsEval, authorizeOne: func(receiver models.Identified) ac.Evaluator { - return ac.EvalAll(readRedactedReceiverEval(receiver.GetUID()), updateReceiverEval(receiver.GetUID())) + return updateReceiverEval(receiver.GetUID()) }, - authorizeAll: ac.EvalAll(readRedactedAllReceiversEval, updateAllReceiversEval), + authorizeAll: updateAllReceiversEval, }, - delete: actionAccess[models.Identified]{ + delete: actionAccess[T]{ genericService: genericService{ ac: a, }, resource: "receiver", action: "delete", - authorizeSome: ac.EvalAll(readRedactedReceiversPreConditionsEval, deleteReceiversPreConditionsEval), + authorizeSome: deleteReceiversPreConditionsEval, authorizeOne: func(receiver models.Identified) ac.Evaluator { - return ac.EvalAll(readRedactedReceiverEval(receiver.GetUID()), deleteReceiverEval(receiver.GetUID())) + return deleteReceiverEval(receiver.GetUID()) }, - authorizeAll: ac.EvalAll(readRedactedAllReceiversEval, deleteAllReceiversEval), + authorizeAll: deleteAllReceiversEval, }, } + // If this service is meant for the provisioning API, we include the provisioning actions as possible permissions. + if includeProvisioningActions { + extendAccessControl(&rcvAccess.read, ac.EvalAny, actionAccess[T]{ + authorizeSome: provisioningExtraReadRedactedPermissions, + authorizeAll: provisioningExtraReadRedactedPermissions, + authorizeOne: func(receiver models.Identified) ac.Evaluator { + return provisioningExtraReadRedactedPermissions + }, + }) + extendAccessControl(&rcvAccess.readDecrypted, ac.EvalAny, actionAccess[T]{ + authorizeSome: provisioningExtraReadDecryptedPermissions, + authorizeAll: provisioningExtraReadDecryptedPermissions, + authorizeOne: func(receiver models.Identified) ac.Evaluator { + return provisioningExtraReadDecryptedPermissions + }, + }) + } + + // Write and delete permissions should require read permissions. + extendAccessControl(&rcvAccess.update, ac.EvalAll, rcvAccess.read) + extendAccessControl(&rcvAccess.delete, ac.EvalAll, rcvAccess.read) + return rcvAccess } +// extendAccessControl extends the access control of base with the extension. The operator function is used to combine +// the authorization evaluators. +func extendAccessControl[T models.Identified](base *actionAccess[T], operator func(evaluator ...ac.Evaluator) ac.Evaluator, extension actionAccess[T]) { + // Prevent infinite recursion. + baseSome := base.authorizeSome + baseAll := base.authorizeAll + baseOne := base.authorizeOne + + // Extend the access control of base with the extension. + base.authorizeSome = operator(extension.authorizeSome, baseSome) + base.authorizeAll = operator(extension.authorizeAll, baseAll) + base.authorizeOne = func(resource models.Identified) ac.Evaluator { + return operator(extension.authorizeOne(resource), baseOne(resource)) + } +} + // HasList checks if user has access to list redacted receivers. Returns false if user does not have access. func (s ReceiverAccess[T]) HasList(ctx context.Context, user identity.Requester) (bool, error) { // TODO: Remove this with fgac. return s.read.HasAccess(ctx, user, readRedactedReceiversListEval) @@ -307,3 +322,72 @@ func (s ReceiverAccess[T]) AuthorizeUpdateByUID(ctx context.Context, user identi func (s ReceiverAccess[T]) AuthorizeReadSome(ctx context.Context, user identity.Requester) error { return s.read.AuthorizePreConditions(ctx, user) } + +// All access permissions for a given receiver. + +// Access returns the permission sets for a slice of receivers. The permission set includes secrets, write, and +// delete which corresponds the given user being able to read, write, and delete each given receiver. +func (s ReceiverAccess[T]) Access(ctx context.Context, user identity.Requester, receivers ...T) (map[string]models.ReceiverPermissionSet, error) { + basePerms := models.NewReceiverPermissionSet() + if err := s.readDecrypted.AuthorizePreConditions(ctx, user); err != nil { + basePerms.Set(models.ReceiverPermissionReadSecret, false) // Doesn't match the preconditions. + } else if err := s.readDecrypted.AuthorizeAll(ctx, user); err == nil { + basePerms.Set(models.ReceiverPermissionReadSecret, true) // Has access to all receivers. + } + + // TODO: Add when resource permissions are implemented. + //if err := s.permissions.AuthorizePreConditions(ctx, user); err != nil { + // basePerms.Set(models.ReceiverPermissionAdmin, false) // Doesn't match the preconditions. + //} else if err := s.permissions.AuthorizeAll(ctx, user); err == nil { + // basePerms.Set(models.ReceiverPermissionAdmin, true) // Has access to all receivers. + //} + + if err := s.update.AuthorizePreConditions(ctx, user); err != nil { + basePerms.Set(models.ReceiverPermissionWrite, false) // Doesn't match the preconditions. + } else if err := s.update.AuthorizeAll(ctx, user); err == nil { + basePerms.Set(models.ReceiverPermissionWrite, true) // Has access to all receivers. + } + + if err := s.delete.AuthorizePreConditions(ctx, user); err != nil { + basePerms.Set(models.ReceiverPermissionDelete, false) // Doesn't match the preconditions. + } else if err := s.delete.AuthorizeAll(ctx, user); err == nil { + basePerms.Set(models.ReceiverPermissionDelete, true) // Has access to all receivers. + } + + if basePerms.AllSet() { + // Shortcut for the case when all permissions are known based on preconditions. + result := make(map[string]models.ReceiverPermissionSet, len(receivers)) + for _, rcv := range receivers { + result[rcv.GetUID()] = basePerms.Clone() + } + return result, nil + } + + result := make(map[string]models.ReceiverPermissionSet, len(receivers)) + for _, rcv := range receivers { + permSet := basePerms.Clone() + if _, ok := permSet.Has(models.ReceiverPermissionReadSecret); !ok { + err := s.readDecrypted.authorize(ctx, user, rcv) // Check permissions ignoring preconditions and all access. + permSet.Set(models.ReceiverPermissionReadSecret, err == nil) + } + + // TODO: Add when resource permissions are implemented. + //if _, ok := permSet.Has(models.ReceiverPermissionAdmin); !ok { + // err := s.permissions.authorize(ctx, user, rcv) + // permSet.Set(models.ReceiverPermissionAdmin, err == nil) + //} + + if _, ok := permSet.Has(models.ReceiverPermissionWrite); !ok { + err := s.update.authorize(ctx, user, rcv) + permSet.Set(models.ReceiverPermissionWrite, err == nil) + } + + if _, ok := permSet.Has(models.ReceiverPermissionDelete); !ok { + err := s.delete.authorize(ctx, user, rcv) + permSet.Set(models.ReceiverPermissionDelete, err == nil) + } + + result[rcv.GetUID()] = permSet + } + return result, nil +} diff --git a/pkg/services/ngalert/accesscontrol/receivers_test.go b/pkg/services/ngalert/accesscontrol/receivers_test.go new file mode 100644 index 00000000000..23b0c635042 --- /dev/null +++ b/pkg/services/ngalert/accesscontrol/receivers_test.go @@ -0,0 +1,351 @@ +package accesscontrol + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/org" +) + +func TestReceiverAccess(t *testing.T) { + recv1 := models.ReceiverGen(models.ReceiverMuts.WithName("test receiver 1"), models.ReceiverMuts.WithValidIntegration("slack"))() + recv2 := models.ReceiverGen(models.ReceiverMuts.WithName("test receiver 2"), models.ReceiverMuts.WithValidIntegration("email"))() + recv3 := models.ReceiverGen(models.ReceiverMuts.WithName("test receiver 3"), models.ReceiverMuts.WithValidIntegration("webhook"))() + + allReceivers := []*models.Receiver{ + &recv1, + &recv2, + &recv3, + } + + permissions := func(perms ...models.ReceiverPermission) models.ReceiverPermissionSet { + set := models.NewReceiverPermissionSet() + for _, v := range models.ReceiverPermissions() { + set.Set(v, false) + } + for _, v := range perms { + set.Set(v, true) + } + return set + } + + testCases := []struct { + name string + user identity.Requester + expected map[string]models.ReceiverPermissionSet + expectedWithProvisioning map[string]models.ReceiverPermissionSet + }{ + // Legacy read. + { + name: "legacy global reader should have no elevated permissions", + user: newEmptyUser(ac.Permission{Action: ac.ActionAlertingNotificationsRead}), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(), + recv2.UID: permissions(), + recv3.UID: permissions(), + }, + }, + { + name: "legacy global notifications provisioning reader should have no elevated permissions", + user: newEmptyUser(ac.Permission{Action: ac.ActionAlertingNotificationsProvisioningRead}), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(), + recv2.UID: permissions(), + recv3.UID: permissions(), + }, + }, + { + name: "legacy global provisioning reader should have no elevated permissions", + user: newEmptyUser(ac.Permission{Action: ac.ActionAlertingProvisioningRead}), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(), + recv2.UID: permissions(), + recv3.UID: permissions(), + }, + }, + { + name: "legacy global provisioning secret reader should have secret permissions on provisioning only", + user: newEmptyUser(ac.Permission{Action: ac.ActionAlertingProvisioningReadSecrets}), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(), + recv2.UID: permissions(), + recv3.UID: permissions(), + }, + expectedWithProvisioning: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(models.ReceiverPermissionReadSecret), + recv2.UID: permissions(models.ReceiverPermissionReadSecret), + recv3.UID: permissions(models.ReceiverPermissionReadSecret), + }, + }, + // Receiver read. + { + name: "global receiver reader should have no elevated permissions", + user: newEmptyUser(ac.Permission{Action: ac.ActionAlertingReceiversRead, Scope: ScopeReceiversAll}), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(), + recv2.UID: permissions(), + recv3.UID: permissions(), + }, + }, + { + name: "global receiver secret reader should have secret permissions", + user: newEmptyUser(ac.Permission{Action: ac.ActionAlertingReceiversReadSecrets, Scope: ScopeReceiversAll}), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(models.ReceiverPermissionReadSecret), + recv2.UID: permissions(models.ReceiverPermissionReadSecret), + recv3.UID: permissions(models.ReceiverPermissionReadSecret), + }, + }, + { + name: "per-receiver secret reader should have per-receiver", + user: newEmptyUser( + ac.Permission{Action: ac.ActionAlertingReceiversReadSecrets, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv1.UID)}, + ac.Permission{Action: ac.ActionAlertingReceiversReadSecrets, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv3.UID)}, + ), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(models.ReceiverPermissionReadSecret), + recv2.UID: permissions(), + recv3.UID: permissions(models.ReceiverPermissionReadSecret), + }, + }, + // Legacy write. + { + name: "legacy global writer should have full write", + user: newViewUser(ac.Permission{Action: ac.ActionAlertingNotificationsWrite}), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionDelete), + recv2.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionDelete), + recv3.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionDelete), + }, + }, + { + name: "legacy writers should require read", + user: newEmptyUser(ac.Permission{Action: ac.ActionAlertingNotificationsWrite}), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(), + recv2.UID: permissions(), + 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{ + // recv1.UID: permissions(), + // recv2.UID: permissions(), + // recv3.UID: permissions(), + // }, + // expectedWithProvisioning: map[string]models.ReceiverPermissionSet{ + // recv1.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionDelete), + // 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{ + // recv1.UID: permissions(), + // recv2.UID: permissions(), + // recv3.UID: permissions(), + // }, + // expectedWithProvisioning: map[string]models.ReceiverPermissionSet{ + // recv1.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionDelete), + // recv2.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionDelete), + // recv3.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionDelete), + // }, + //}, + // Receiver create + { + name: "receiver create should not have write", + user: newEmptyUser(ac.Permission{Action: ac.ActionAlertingReceiversCreate}), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(), + recv2.UID: permissions(), + recv3.UID: permissions(), + }, + }, + // Receiver update. + { + name: "global receiver update should have write but no delete", + user: newViewUser(ac.Permission{Action: ac.ActionAlertingReceiversUpdate, Scope: ScopeReceiversAll}), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(models.ReceiverPermissionWrite), + recv2.UID: permissions(models.ReceiverPermissionWrite), + recv3.UID: permissions(models.ReceiverPermissionWrite), + }, + }, + { + name: "per-receiver update should have per-receiver write but no delete", + user: newViewUser( + ac.Permission{Action: ac.ActionAlertingReceiversUpdate, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv1.UID)}, + ac.Permission{Action: ac.ActionAlertingReceiversUpdate, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv3.UID)}, + ), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(models.ReceiverPermissionWrite), + recv2.UID: permissions(), + recv3.UID: permissions(models.ReceiverPermissionWrite), + }, + }, + { + name: "per-receiver update should require read", + user: newEmptyUser( + ac.Permission{Action: ac.ActionAlertingReceiversUpdate, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv1.UID)}, + ac.Permission{Action: ac.ActionAlertingReceiversUpdate, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv3.UID)}, + ), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(), + recv2.UID: permissions(), + recv3.UID: permissions(), + }, + }, + // Receiver delete. + { + name: "global receiver delete should have delete but no write", + user: newViewUser(ac.Permission{Action: ac.ActionAlertingReceiversDelete, Scope: ScopeReceiversAll}), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(models.ReceiverPermissionDelete), + recv2.UID: permissions(models.ReceiverPermissionDelete), + recv3.UID: permissions(models.ReceiverPermissionDelete), + }, + }, + { + name: "per-receiver delete should have per-receiver delete but no write", + user: newViewUser( + ac.Permission{Action: ac.ActionAlertingReceiversDelete, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv1.UID)}, + ac.Permission{Action: ac.ActionAlertingReceiversDelete, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv3.UID)}, + ), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(models.ReceiverPermissionDelete), + recv2.UID: permissions(), + recv3.UID: permissions(models.ReceiverPermissionDelete), + }, + }, + { + name: "per-receiver delete should require read", + user: newEmptyUser( + ac.Permission{Action: ac.ActionAlertingReceiversDelete, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv1.UID)}, + ac.Permission{Action: ac.ActionAlertingReceiversDelete, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv3.UID)}, + ), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(), + recv2.UID: permissions(), + recv3.UID: permissions(), + }, + }, + // Mixed permissions. + { + name: "legacy provisioning secret read, receiver write", + user: newViewUser( + ac.Permission{Action: ac.ActionAlertingProvisioningReadSecrets}, + ac.Permission{Action: ac.ActionAlertingReceiversUpdate, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv2.UID)}, + ), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(), + recv2.UID: permissions(models.ReceiverPermissionWrite), + recv3.UID: permissions(), + }, + expectedWithProvisioning: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(models.ReceiverPermissionReadSecret), + recv2.UID: permissions(models.ReceiverPermissionReadSecret, models.ReceiverPermissionWrite), + recv3.UID: permissions(models.ReceiverPermissionReadSecret), + }, + }, + { + name: "legacy provisioning secret read, receiver delete", + user: newViewUser( + ac.Permission{Action: ac.ActionAlertingProvisioningReadSecrets}, + ac.Permission{Action: ac.ActionAlertingReceiversDelete, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv2.UID)}, + ), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(), + recv2.UID: permissions(models.ReceiverPermissionDelete), + recv3.UID: permissions(), + }, + expectedWithProvisioning: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(models.ReceiverPermissionReadSecret), + recv2.UID: permissions(models.ReceiverPermissionReadSecret, models.ReceiverPermissionDelete), + recv3.UID: permissions(models.ReceiverPermissionReadSecret), + }, + }, + { + name: "legacy write, receiver secret", + user: newViewUser( + ac.Permission{Action: ac.ActionAlertingNotificationsWrite}, + ac.Permission{Action: ac.ActionAlertingReceiversReadSecrets, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv2.UID)}, + ), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionDelete), + recv2.UID: permissions(models.ReceiverPermissionReadSecret, models.ReceiverPermissionWrite, models.ReceiverPermissionDelete), + recv3.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionDelete), + }, + }, + { + name: "mixed secret / delete / write", + user: newViewUser( + ac.Permission{Action: ac.ActionAlertingReceiversReadSecrets, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv1.UID)}, + ac.Permission{Action: ac.ActionAlertingReceiversReadSecrets, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv3.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.ActionAlertingReceiversDelete, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv2.UID)}, + ac.Permission{Action: ac.ActionAlertingReceiversDelete, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv3.UID)}, + ), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(models.ReceiverPermissionReadSecret, models.ReceiverPermissionWrite), + recv2.UID: permissions(models.ReceiverPermissionWrite, models.ReceiverPermissionDelete), + recv3.UID: permissions(models.ReceiverPermissionReadSecret, models.ReceiverPermissionDelete), + }, + }, + { + name: "mixed requires read", + user: newEmptyUser( + ac.Permission{Action: ac.ActionAlertingReceiversReadSecrets, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv1.UID)}, + ac.Permission{Action: ac.ActionAlertingReceiversReadSecrets, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv3.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.ActionAlertingReceiversDelete, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv2.UID)}, + ac.Permission{Action: ac.ActionAlertingReceiversDelete, Scope: ScopeReceiversProvider.GetResourceScopeUID(recv3.UID)}, + ), + expected: map[string]models.ReceiverPermissionSet{ + recv1.UID: permissions(models.ReceiverPermissionReadSecret, models.ReceiverPermissionWrite), + recv2.UID: permissions(), + recv3.UID: permissions(models.ReceiverPermissionReadSecret, models.ReceiverPermissionDelete), + }, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + svc := NewReceiverAccess[*models.Receiver](&recordingAccessControlFake{}, false) + + actual, err := svc.Access(context.Background(), testCase.user, allReceivers...) + + assert.NoError(t, err) + assert.Equalf(t, testCase.expected, actual, "expected: %v, actual: %v", testCase.expected, actual) + + provisioningPerms := testCase.expected + if testCase.expectedWithProvisioning != nil { + provisioningPerms = testCase.expectedWithProvisioning + } + svc = NewReceiverAccess[*models.Receiver](&recordingAccessControlFake{}, true) + actual, err = svc.Access(context.Background(), testCase.user, allReceivers...) + assert.NoError(t, err) + assert.Equalf(t, provisioningPerms, actual, "expectedWithProvisioning: %v, actual: %v", provisioningPerms, actual) + }) + } +} + +func newEmptyUser(permissions ...ac.Permission) identity.Requester { + return ac.BackgroundUser("test", orgID, org.RoleNone, permissions) +} + +func newViewUser(permissions ...ac.Permission) identity.Requester { + return ac.BackgroundUser("test", orgID, org.RoleNone, append([]ac.Permission{ + {Action: ac.ActionAlertingReceiversRead, Scope: ScopeReceiversAll}, + {Action: ac.ActionAlertingNotificationsRead}, + }, permissions...)) +} diff --git a/pkg/services/ngalert/models/permissions.go b/pkg/services/ngalert/models/permissions.go new file mode 100644 index 00000000000..81e07c5a3d5 --- /dev/null +++ b/pkg/services/ngalert/models/permissions.go @@ -0,0 +1,75 @@ +package models + +import ( + "maps" + "slices" +) + +// ReceiverPermission is a type for representing permission to perform a receiver action. +type ReceiverPermission string + +const ( + ReceiverPermissionReadSecret ReceiverPermission = "secrets" + //ReceiverPermissionAdmin ReceiverPermission = "admin" // TODO: Add when resource permissions are implemented. + ReceiverPermissionWrite ReceiverPermission = "write" + ReceiverPermissionDelete ReceiverPermission = "delete" +) + +// ReceiverPermissions returns all possible silence permissions. +func ReceiverPermissions() []ReceiverPermission { + return []ReceiverPermission{ + ReceiverPermissionReadSecret, + //ReceiverPermissionAdmin, // TODO: Add when resource permissions are implemented. + ReceiverPermissionWrite, + ReceiverPermissionDelete, + } +} + +// ReceiverPermissionSet represents a set of permissions for a receiver. +type ReceiverPermissionSet = PermissionSet[ReceiverPermission] + +func NewReceiverPermissionSet() ReceiverPermissionSet { + return NewPermissionSet(ReceiverPermissions()) +} + +// PermissionSet represents a set of permissions on a resource. +type PermissionSet[T ~string] struct { + set map[T]bool + all []T +} + +func NewPermissionSet[T ~string](all []T) PermissionSet[T] { + return PermissionSet[T]{ + set: make(map[T]bool), + all: slices.Clone(all), + } +} + +// Clone returns a deep copy of the permission set. +func (p PermissionSet[T]) Clone() PermissionSet[T] { + return PermissionSet[T]{ + set: maps.Clone(p.set), + all: p.all, + } +} + +// AllSet returns true if all possible permissions are set. +func (p PermissionSet[T]) AllSet() bool { + for _, permission := range p.all { + if _, ok := p.set[permission]; !ok { + return false + } + } + return true +} + +// Has returns true if the given permission is allowed in the set. +func (p PermissionSet[T]) Has(permission T) (bool, bool) { + allowed, ok := p.set[permission] + return allowed, ok +} + +// Set sets the given permission to the given allowed state. +func (p PermissionSet[T]) Set(permission T, allowed bool) { + p.set[permission] = allowed +} diff --git a/pkg/services/ngalert/models/silence.go b/pkg/services/ngalert/models/silence.go index f0b97609524..b8d2638b18f 100644 --- a/pkg/services/ngalert/models/silence.go +++ b/pkg/services/ngalert/models/silence.go @@ -75,7 +75,7 @@ func SilencePermissions() [3]SilencePermission { } // SilencePermissionSet represents a set of permissions for a silence. -type SilencePermissionSet map[SilencePermission]bool +type SilencePermissionSet map[SilencePermission]bool // TODO: Implement using PermissionSet[SilencePermission] // Clone returns a deep copy of the permission set. func (p SilencePermissionSet) Clone() SilencePermissionSet { diff --git a/pkg/services/ngalert/notifier/receiver_svc_test.go b/pkg/services/ngalert/notifier/receiver_svc_test.go index 8fabd53851c..309cb2fe352 100644 --- a/pkg/services/ngalert/notifier/receiver_svc_test.go +++ b/pkg/services/ngalert/notifier/receiver_svc_test.go @@ -825,7 +825,8 @@ func TestReceiverServiceAC_Read(t *testing.T) { permissions map[string][]string existing []models.Receiver - visible []models.Receiver + visible []models.Receiver + visibleWithProvisioning []models.Receiver }{ { name: "not authorized without permissions", @@ -874,6 +875,20 @@ func TestReceiverServiceAC_Read(t *testing.T) { existing: allReceivers(), visible: []models.Receiver{recv1, recv3}, }, + { + name: "provisioning read applies to only provisioning", + permissions: map[string][]string{accesscontrol.ActionAlertingProvisioningRead: nil}, + existing: allReceivers(), + visible: nil, + visibleWithProvisioning: allReceivers(), + }, + { + name: "provisioning read secrets applies to only provisioning", + permissions: map[string][]string{accesscontrol.ActionAlertingProvisioningReadSecrets: nil}, + existing: allReceivers(), + visible: nil, + visibleWithProvisioning: allReceivers(), + }, } for _, tc := range testCases { @@ -906,6 +921,28 @@ func TestReceiverServiceAC_Read(t *testing.T) { assert.ErrorIsf(t, err, ac.ErrAuthorizationBase, "receiver '%s' should not be visible, but is", recv.Name) } } + + isVisibleInProvisioning := func(uid string) bool { + if tc.visibleWithProvisioning == nil { + return isVisible(uid) + } + for _, recv := range tc.visibleWithProvisioning { + if recv.UID == uid { + return true + } + } + return false + } + sut.authz = ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()), true) + for _, recv := range allReceivers() { + response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr) + if isVisibleInProvisioning(recv.UID) { + require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name) + assert.NotNil(t, response) + } else { + assert.ErrorIsf(t, err, ac.ErrAuthorizationBase, "receiver '%s' should not be visible, but is", recv.Name) + } + } }) } } @@ -933,14 +970,14 @@ func TestReceiverServiceAC_Create(t *testing.T) { hasAccess: nil, }, { - name: "global legacy permissions - not authorized without read", + name: "global legacy permissions - authorized without read", permissions: map[string][]string{accesscontrol.ActionAlertingNotificationsWrite: nil}, - hasAccess: nil, + hasAccess: allReceivers(), }, { - name: "receivers permissions - not authorized without read", + name: "receivers permissions - authorized without read", permissions: map[string][]string{accesscontrol.ActionAlertingReceiversCreate: nil}, - hasAccess: nil, + hasAccess: allReceivers(), }, { name: "global legacy permissions - create all", diff --git a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go index 713492c4f37..3b9fadd7cff 100644 --- a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go +++ b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go @@ -133,11 +133,12 @@ func TestIntegrationAccessControl(t *testing.T) { org1 := helper.Org1 type testCase struct { - user apis.User - canRead bool - canUpdate bool - canCreate bool - canDelete bool + user apis.User + canRead bool + canUpdate bool + canCreate bool + canDelete bool + canReadSecrets bool } // region users unauthorized := helper.CreateUser("unauthorized", "Org1", org.RoleNone, []resourcepermissions.SetResourcePermissionCommand{}) @@ -215,8 +216,9 @@ func TestIntegrationAccessControl(t *testing.T) { canRead: true, }, { - user: secretsReader, - canRead: true, + user: secretsReader, + canRead: true, + canReadSecrets: true, }, { user: creator, @@ -298,6 +300,16 @@ func TestIntegrationAccessControl(t *testing.T) { } if tc.canRead { + expectedWithMetadata := expected.DeepCopy() + if tc.canUpdate { + expectedWithMetadata.SetAccessControl("canWrite") + } + if tc.canDelete { + expectedWithMetadata.SetAccessControl("canDelete") + } + if tc.canReadSecrets { + expectedWithMetadata.SetAccessControl("canReadSecrets") + } t.Run("should be able to list receivers", func(t *testing.T) { list, err := client.List(ctx, v1.ListOptions{}) require.NoError(t, err) @@ -307,7 +319,7 @@ func TestIntegrationAccessControl(t *testing.T) { t.Run("should be able to read receiver by resource identifier", func(t *testing.T) { got, err := client.Get(ctx, expected.Name, v1.GetOptions{}) require.NoError(t, err) - require.Equal(t, expected, got) + require.Equal(t, expectedWithMetadata, got) t.Run("should get NotFound if resource does not exist", func(t *testing.T) { _, err := client.Get(ctx, "Notfound", v1.GetOptions{}) @@ -871,6 +883,10 @@ func TestIntegrationCRUD(t *testing.T) { require.NoError(t, err) require.Len(t, receiver.Spec.Integrations, len(integrations)) + // Set access control metadata + receiver.SetAccessControl("canWrite") + receiver.SetAccessControl("canDelete") + // Use export endpoint because it's the only way to get decrypted secrets fast. cliCfg := helper.Org1.Admin.NewRestConfig() legacyCli := alerting.NewAlertingLegacyAPIClient(helper.GetEnv().Server.HTTPServer.Listener.Addr().String(), cliCfg.Username, cliCfg.Password)