RBCA: Better separation between action set svc and store (#91491)

better separation between action set svc and store
This commit is contained in:
Ieva 2024-08-12 10:07:33 +01:00 committed by GitHub
parent 5bae9f11bc
commit 6e7bc028d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 315 additions and 261 deletions

View File

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"sort"
"strings"
"golang.org/x/exp/slices"
@ -12,7 +13,9 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/licensing"
@ -23,7 +26,7 @@ import (
"github.com/grafana/grafana/pkg/setting"
)
var _ pluginaccesscontrol.ActionSetRegistry = (*InMemoryActionSets)(nil)
var _ pluginaccesscontrol.ActionSetRegistry = (ActionSetService)(nil)
type Store interface {
// SetUserResourcePermission sets permission for managed user role on a resource
@ -437,7 +440,7 @@ type ActionSetService interface {
ResolveAction(action string) []string
// ResolveActionSet resolves an action set to a list of corresponding actions.
ResolveActionSet(actionSet string) []string
// StoreActionSet stores action set. If a set with the given name has already been stored, the new actions will be appended to the existing actions.
StoreActionSet(name string, actions []string)
pluginaccesscontrol.ActionSetRegistry
@ -450,21 +453,126 @@ type ActionSet struct {
Actions []string `json:"actions"`
}
// InMemoryActionSets is an in-memory implementation of the ActionSetService.
type InMemoryActionSets struct {
features featuremgmt.FeatureToggles
log log.Logger
actionSetToActions map[string][]string
actionToActionSets map[string][]string
type ActionSetStore interface {
// StoreActionSet stores action set. If a set with the given name has already been stored, the new actions will be appended to the existing actions.
StoreActionSet(name string, actions []string)
// ResolveActionSet resolves an action set to a list of corresponding actions.
ResolveActionSet(actionSet string) []string
// ResolveAction returns all the action sets that the action belongs to.
ResolveAction(action string) []string
// ResolveActionPrefix returns all action sets that include at least one action with the specified prefix
ResolveActionPrefix(prefix string) []string
// ExpandActionSetsWithFilter takes a set of permissions that might include some action set permissions, and returns a set of permissions with action sets expanded into underlying permissions.
// When action sets are expanded into the underlying permissions only those permissions whose action is matched by actionMatcher are included.
ExpandActionSetsWithFilter(permissions []accesscontrol.Permission, actionMatcher func(action string) bool) []accesscontrol.Permission
}
type ActionSetSvc struct {
features featuremgmt.FeatureToggles
store ActionSetStore
}
// NewActionSetService returns a new instance of InMemoryActionSetService.
func NewActionSetService(features featuremgmt.FeatureToggles) ActionSetService {
actionSets := &InMemoryActionSets{
features: features,
log: log.New("resourcepermissions.actionsets"),
actionSetToActions: make(map[string][]string),
actionToActionSets: make(map[string][]string),
return &ActionSetSvc{
features: features,
store: NewInMemoryActionSetStore(features),
}
return actionSets
}
// ResolveAction returns all the action sets that the action belongs to.
func (a *ActionSetSvc) ResolveAction(action string) []string {
sets := a.store.ResolveAction(action)
filteredSets := make([]string, 0, len(sets))
for _, set := range sets {
// Only use action sets for folders and dashboards for now
// We need to verify that action sets for other resources do not share names with actions (eg, `datasources:read`)
if !isFolderOrDashboardAction(set) {
continue
}
filteredSets = append(filteredSets, set)
}
return filteredSets
}
// ResolveActionPrefix returns all action sets that include at least one action with the specified prefix
func (a *ActionSetSvc) ResolveActionPrefix(actionPrefix string) []string {
sets := a.store.ResolveActionPrefix(actionPrefix)
filteredSets := make([]string, 0, len(sets))
for _, set := range sets {
// Only use action sets for folders and dashboards for now
// We need to verify that action sets for other resources do not share names with actions (eg, `datasources:read`)
if !isFolderOrDashboardAction(set) {
continue
}
filteredSets = append(filteredSets, set)
}
return filteredSets
}
// ResolveActionSet resolves an action set to a list of corresponding actions.
func (a *ActionSetSvc) ResolveActionSet(actionSet string) []string {
// Only use action sets for folders and dashboards for now
// We need to verify that action sets for other resources do not share names with actions (eg, `datasources:read`)
if !isFolderOrDashboardAction(actionSet) {
return nil
}
return a.store.ResolveActionSet(actionSet)
}
// StoreActionSet stores action set. If a set with the given name has already been stored, the new actions will be appended to the existing actions.
func (a *ActionSetSvc) StoreActionSet(name string, actions []string) {
// To avoid backwards incompatible changes, we don't want to store these actions in the DB
// Once action sets are fully enabled, we can include dashboards.ActionFoldersCreate in the list of other folder edit/admin actions
// Tracked in https://github.com/grafana/identity-access-team/issues/794
if name == "folders:edit" || name == "folders:admin" {
if !slices.Contains(a.ResolveActionSet(name), dashboards.ActionFoldersCreate) {
actions = append(actions, dashboards.ActionFoldersCreate)
}
}
a.store.StoreActionSet(name, actions)
}
// ExpandActionSets takes a set of permissions that might include some action set permissions, and returns a set of permissions with action sets expanded into underlying permissions
func (a *ActionSetSvc) ExpandActionSets(permissions []accesscontrol.Permission) []accesscontrol.Permission {
actionMatcher := func(_ string) bool {
return true
}
return a.ExpandActionSetsWithFilter(permissions, actionMatcher)
}
// ExpandActionSetsWithFilter works like ExpandActionSets, but it also takes a function for action filtering. When action sets are expanded into the underlying permissions,
// only those permissions whose action is matched by actionMatcher are included.
func (a *ActionSetSvc) ExpandActionSetsWithFilter(permissions []accesscontrol.Permission, actionMatcher func(action string) bool) []accesscontrol.Permission {
return a.store.ExpandActionSetsWithFilter(permissions, actionMatcher)
}
// RegisterActionSets allow the caller to expand the existing action sets with additional permissions
// This is intended to be used by plugins, and currently supports extending folder and dashboard action sets
func (a *ActionSetSvc) RegisterActionSets(ctx context.Context, pluginID string, registrations []plugins.ActionSet) error {
if !a.features.IsEnabled(ctx, featuremgmt.FlagAccessActionSets) || !a.features.IsEnabled(ctx, featuremgmt.FlagAccessControlOnCall) {
return nil
}
for _, reg := range registrations {
if err := pluginutils.ValidatePluginActionSet(pluginID, reg); err != nil {
return err
}
a.StoreActionSet(reg.Action, reg.Actions)
}
return nil
}
func isFolderOrDashboardAction(action string) bool {
return strings.HasPrefix(action, dashboards.ScopeDashboardsRoot) || strings.HasPrefix(action, dashboards.ScopeFoldersRoot)
}
// GetActionSetName function creates an action set from a list of actions and stores it inmemory.
func GetActionSetName(resource, permission string) string {
// lower cased
resource = strings.ToLower(resource)
permission = strings.ToLower(permission)
return fmt.Sprintf("%s:%s", resource, permission)
}

View File

@ -10,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
@ -314,6 +315,177 @@ func TestService_RegisterActionSets(t *testing.T) {
}
}
func TestStore_RegisterActionSet(t *testing.T) {
type actionSetTest struct {
desc string
features featuremgmt.FeatureToggles
pluginID string
pluginActions []plugins.ActionSet
coreActionSets []ActionSet
expectedErr bool
expectedActionSets []ActionSet
}
tests := []actionSetTest{
{
desc: "should be able to register a plugin action set if the right feature toggles are enabled",
features: featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets, featuremgmt.FlagAccessControlOnCall),
pluginID: "test-app",
pluginActions: []plugins.ActionSet{
{
Action: "folders:view",
Actions: []string{"test-app.resource:read"},
},
},
expectedActionSets: []ActionSet{
{
Action: "folders:view",
Actions: []string{"test-app.resource:read"},
},
},
},
{
desc: "should not register plugin action set if feature toggles are missing",
features: featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall),
pluginID: "test-app",
pluginActions: []plugins.ActionSet{
{
Action: "folders:view",
Actions: []string{"test-app.resource:read"},
},
},
expectedActionSets: []ActionSet{},
},
{
desc: "should be able to register multiple plugin action sets",
features: featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets, featuremgmt.FlagAccessControlOnCall),
pluginID: "test-app",
pluginActions: []plugins.ActionSet{
{
Action: "folders:view",
Actions: []string{"test-app.resource:read"},
},
{
Action: "folders:edit",
Actions: []string{"test-app.resource:write", "test-app.resource:delete"},
},
},
expectedActionSets: []ActionSet{
{
Action: "folders:view",
Actions: []string{"test-app.resource:read"},
},
{
Action: "folders:edit",
Actions: []string{"test-app.resource:write", "test-app.resource:delete"},
},
},
},
{
desc: "action set actions should be added not replaced",
features: featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets, featuremgmt.FlagAccessControlOnCall),
pluginID: "test-app",
pluginActions: []plugins.ActionSet{
{
Action: "folders:view",
Actions: []string{"test-app.resource:read"},
},
{
Action: "folders:edit",
Actions: []string{"test-app.resource:write", "test-app.resource:delete"},
},
},
coreActionSets: []ActionSet{
{
Action: "folders:view",
Actions: []string{"folders:read"},
},
{
Action: "folders:edit",
Actions: []string{"folders:write", "folders:delete"},
},
{
Action: "folders:admin",
Actions: []string{"folders.permissions:read"},
},
},
expectedActionSets: []ActionSet{
{
Action: "folders:view",
Actions: []string{"folders:read", "test-app.resource:read"},
},
{
Action: "folders:edit",
Actions: []string{"folders:write", "test-app.resource:write", "folders:delete", "test-app.resource:delete"},
},
{
Action: "folders:admin",
Actions: []string{"folders.permissions:read"},
},
},
},
{
desc: "should not be able to register an action that doesn't have a plugin prefix",
features: featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets, featuremgmt.FlagAccessControlOnCall),
pluginID: "test-app",
pluginActions: []plugins.ActionSet{
{
Action: "folders:view",
Actions: []string{"test-app.resource:read"},
},
{
Action: "folders:edit",
Actions: []string{"users:read", "test-app.resource:delete"},
},
},
expectedErr: true,
},
{
desc: "should not be able to register action set that is not in the allow list",
features: featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets, featuremgmt.FlagAccessControlOnCall),
pluginID: "test-app",
pluginActions: []plugins.ActionSet{
{
Action: "folders:super-admin",
Actions: []string{"test-app.resource:read"},
},
},
expectedErr: true,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
asService := NewActionSetService(tt.features)
err := asService.RegisterActionSets(context.Background(), tt.pluginID, tt.pluginActions)
if tt.expectedErr {
require.Error(t, err)
return
}
require.NoError(t, err)
for _, set := range tt.coreActionSets {
asService.StoreActionSet(set.Action, set.Actions)
}
for _, expected := range tt.expectedActionSets {
actions := asService.ResolveActionSet(expected.Action)
if expected.Action == "folders:edit" || expected.Action == "folders:admin" {
expected.Actions = append(expected.Actions, "folders:create")
}
assert.ElementsMatch(t, expected.Actions, actions)
}
if len(tt.expectedActionSets) == 0 {
for _, set := range tt.pluginActions {
registeredActions := asService.ResolveActionSet(set.Action)
assert.Empty(t, registeredActions, "no actions from plugin action sets should have been registered")
}
}
})
}
}
func setupTestEnvironment(t *testing.T, ops Options) (*Service, user.Service, team.Service) {
t.Helper()

View File

@ -3,15 +3,12 @@ package resourcepermissions
import (
"context"
"fmt"
"slices"
"strings"
"time"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
@ -744,6 +741,23 @@ func managedPermission(action, resource string, resourceID, resourceAttribute st
}
}
// InMemoryActionSets is an in-memory implementation of the ActionSetStore.
type InMemoryActionSets struct {
features featuremgmt.FeatureToggles
log log.Logger
actionSetToActions map[string][]string
actionToActionSets map[string][]string
}
func NewInMemoryActionSetStore(features featuremgmt.FeatureToggles) *InMemoryActionSets {
return &InMemoryActionSets{
actionSetToActions: make(map[string][]string),
actionToActionSets: make(map[string][]string),
log: log.New("resourcepermissions.actionsets"),
features: features,
}
}
// ResolveActionPrefix returns all action sets that include at least one action with the specified prefix
func (s *InMemoryActionSets) ResolveActionPrefix(prefix string) []string {
if prefix == "" {
@ -753,11 +767,6 @@ func (s *InMemoryActionSets) ResolveActionPrefix(prefix string) []string {
sets := make([]string, 0, len(s.actionSetToActions))
for set, actions := range s.actionSetToActions {
// Only use action sets for folders and dashboards for now
// We need to verify that action sets for other resources do not share names with actions (eg, `datasources:read`)
if !isFolderOrDashboardAction(set) {
continue
}
for _, action := range actions {
if strings.HasPrefix(action, prefix) {
sets = append(sets, set)
@ -770,44 +779,13 @@ func (s *InMemoryActionSets) ResolveActionPrefix(prefix string) []string {
}
func (s *InMemoryActionSets) ResolveAction(action string) []string {
actionSets := s.actionToActionSets[action]
sets := make([]string, 0, len(actionSets))
for _, actionSet := range actionSets {
// Only use action sets for folders and dashboards for now
// We need to verify that action sets for other resources do not share names with actions (eg, `datasources:read`)
if !isFolderOrDashboardAction(actionSet) {
continue
}
sets = append(sets, actionSet)
}
return sets
return s.actionToActionSets[action]
}
func (s *InMemoryActionSets) ResolveActionSet(actionSet string) []string {
// Only use action sets for folders and dashboards for now
// We need to verify that action sets for other resources do not share names with actions (eg, `datasources:read`)
if !isFolderOrDashboardAction(actionSet) {
return nil
}
return s.actionSetToActions[actionSet]
}
func isFolderOrDashboardAction(action string) bool {
return strings.HasPrefix(action, dashboards.ScopeDashboardsRoot) || strings.HasPrefix(action, dashboards.ScopeFoldersRoot)
}
// ExpandActionSets takes a set of permissions that might include some action set permissions, and returns a set of permissions with action sets expanded into underlying permissions
func (s *InMemoryActionSets) ExpandActionSets(permissions []accesscontrol.Permission) []accesscontrol.Permission {
actionMatcher := func(_ string) bool {
return true
}
return s.ExpandActionSetsWithFilter(permissions, actionMatcher)
}
// ExpandActionSetsWithFilter works like ExpandActionSets, but it also takes a function for action filtering. When action sets are expanded into the underlying permissions,
// only those permissions whose action is matched by actionMatcher are included.
func (s *InMemoryActionSets) ExpandActionSetsWithFilter(permissions []accesscontrol.Permission, actionMatcher func(action string) bool) []accesscontrol.Permission {
var expandedPermissions []accesscontrol.Permission
for _, permission := range permissions {
@ -828,15 +806,6 @@ func (s *InMemoryActionSets) ExpandActionSetsWithFilter(permissions []accesscont
}
func (s *InMemoryActionSets) StoreActionSet(name string, actions []string) {
// To avoid backwards incompatible changes, we don't want to store these actions in the DB
// Once action sets are fully enabled, we can include dashboards.ActionFoldersCreate in the list of other folder edit/admin actions
// Tracked in https://github.com/grafana/identity-access-team/issues/794
if name == "folders:edit" || name == "folders:admin" {
if !slices.Contains(s.actionSetToActions[name], dashboards.ActionFoldersCreate) {
actions = append(actions, dashboards.ActionFoldersCreate)
}
}
s.actionSetToActions[name] = append(s.actionSetToActions[name], actions...)
for _, action := range actions {
@ -847,26 +816,3 @@ func (s *InMemoryActionSets) StoreActionSet(name string, actions []string) {
}
s.log.Debug("stored action set", "action set name", name)
}
// RegisterActionSets allow the caller to expand the existing action sets with additional permissions
// This is intended to be used by plugins, and currently supports extending folder and dashboard action sets
func (s *InMemoryActionSets) RegisterActionSets(ctx context.Context, pluginID string, registrations []plugins.ActionSet) error {
if !s.features.IsEnabled(ctx, featuremgmt.FlagAccessActionSets) || !s.features.IsEnabled(ctx, featuremgmt.FlagAccessControlOnCall) {
return nil
}
for _, reg := range registrations {
if err := pluginutils.ValidatePluginActionSet(pluginID, reg); err != nil {
return err
}
s.StoreActionSet(reg.Action, reg.Actions)
}
return nil
}
// GetActionSetName function creates an action set from a list of actions and stores it inmemory.
func GetActionSetName(resource, permission string) string {
// lower cased
resource = strings.ToLower(resource)
permission = strings.ToLower(permission)
return fmt.Sprintf("%s:%s", resource, permission)
}

View File

@ -11,7 +11,6 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@ -782,183 +781,12 @@ func TestStore_StoreActionSet(t *testing.T) {
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
asService := NewActionSetService(featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets))
asService := NewInMemoryActionSetStore(featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets))
asService.StoreActionSet(GetActionSetName(tt.resource, tt.action), tt.actions)
actionSetName := GetActionSetName(tt.resource, tt.action)
actionSet := asService.ResolveActionSet(actionSetName)
require.Equal(t, append(tt.actions, "folders:create"), actionSet)
})
}
}
func TestStore_RegisterActionSet(t *testing.T) {
type actionSetTest struct {
desc string
features featuremgmt.FeatureToggles
pluginID string
pluginActions []plugins.ActionSet
coreActionSets []ActionSet
expectedErr bool
expectedActionSets []ActionSet
}
tests := []actionSetTest{
{
desc: "should be able to register a plugin action set if the right feature toggles are enabled",
features: featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets, featuremgmt.FlagAccessControlOnCall),
pluginID: "test-app",
pluginActions: []plugins.ActionSet{
{
Action: "folders:view",
Actions: []string{"test-app.resource:read"},
},
},
expectedActionSets: []ActionSet{
{
Action: "folders:view",
Actions: []string{"test-app.resource:read"},
},
},
},
{
desc: "should not register plugin action set if feature toggles are missing",
features: featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall),
pluginID: "test-app",
pluginActions: []plugins.ActionSet{
{
Action: "folders:view",
Actions: []string{"test-app.resource:read"},
},
},
expectedActionSets: []ActionSet{},
},
{
desc: "should be able to register multiple plugin action sets",
features: featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets, featuremgmt.FlagAccessControlOnCall),
pluginID: "test-app",
pluginActions: []plugins.ActionSet{
{
Action: "folders:view",
Actions: []string{"test-app.resource:read"},
},
{
Action: "folders:edit",
Actions: []string{"test-app.resource:write", "test-app.resource:delete"},
},
},
expectedActionSets: []ActionSet{
{
Action: "folders:view",
Actions: []string{"test-app.resource:read"},
},
{
Action: "folders:edit",
Actions: []string{"test-app.resource:write", "test-app.resource:delete"},
},
},
},
{
desc: "action set actions should be added not replaced",
features: featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets, featuremgmt.FlagAccessControlOnCall),
pluginID: "test-app",
pluginActions: []plugins.ActionSet{
{
Action: "folders:view",
Actions: []string{"test-app.resource:read"},
},
{
Action: "folders:edit",
Actions: []string{"test-app.resource:write", "test-app.resource:delete"},
},
},
coreActionSets: []ActionSet{
{
Action: "folders:view",
Actions: []string{"folders:read"},
},
{
Action: "folders:edit",
Actions: []string{"folders:write", "folders:delete"},
},
{
Action: "folders:admin",
Actions: []string{"folders.permissions:read"},
},
},
expectedActionSets: []ActionSet{
{
Action: "folders:view",
Actions: []string{"folders:read", "test-app.resource:read"},
},
{
Action: "folders:edit",
Actions: []string{"folders:write", "test-app.resource:write", "folders:delete", "test-app.resource:delete"},
},
{
Action: "folders:admin",
Actions: []string{"folders.permissions:read"},
},
},
},
{
desc: "should not be able to register an action that doesn't have a plugin prefix",
features: featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets, featuremgmt.FlagAccessControlOnCall),
pluginID: "test-app",
pluginActions: []plugins.ActionSet{
{
Action: "folders:view",
Actions: []string{"test-app.resource:read"},
},
{
Action: "folders:edit",
Actions: []string{"users:read", "test-app.resource:delete"},
},
},
expectedErr: true,
},
{
desc: "should not be able to register action set that is not in the allow list",
features: featuremgmt.WithFeatures(featuremgmt.FlagAccessActionSets, featuremgmt.FlagAccessControlOnCall),
pluginID: "test-app",
pluginActions: []plugins.ActionSet{
{
Action: "folders:super-admin",
Actions: []string{"test-app.resource:read"},
},
},
expectedErr: true,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
asService := NewActionSetService(tt.features)
err := asService.RegisterActionSets(context.Background(), tt.pluginID, tt.pluginActions)
if tt.expectedErr {
require.Error(t, err)
return
}
require.NoError(t, err)
for _, set := range tt.coreActionSets {
asService.StoreActionSet(set.Action, set.Actions)
}
for _, expected := range tt.expectedActionSets {
actions := asService.ResolveActionSet(expected.Action)
if expected.Action == "folders:edit" || expected.Action == "folders:admin" {
expected.Actions = append(expected.Actions, "folders:create")
}
assert.ElementsMatch(t, expected.Actions, actions)
}
if len(tt.expectedActionSets) == 0 {
for _, set := range tt.pluginActions {
registeredActions := asService.ResolveActionSet(set.Action)
assert.Empty(t, registeredActions, "no actions from plugin action sets should have been registered")
}
}
require.Equal(t, tt.actions, actionSet)
})
}
}