Alerting: Include access control metadata in k8s receiver LIST & GET (#93013)

* Include access control metadata in k8s receiver List & Get

* Add tests for receiver access

* Simplify receiver access provisioning extension

- prevents edge case infinite recursion
- removes read requirement from create
This commit is contained in:
Matthew Jacobson 2024-09-12 13:57:53 -04:00 committed by GitHub
parent 0aa87fd1d4
commit ff6a20f54a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 686 additions and 57 deletions

View File

@ -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)
}

View File

@ -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),

View File

@ -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
}

View File

@ -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())

View File

@ -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)
}

View File

@ -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
}

View File

@ -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...))
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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",

View File

@ -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)