From bf49c20050828900d7938b5055922fed19a39039 Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Wed, 30 Nov 2022 15:38:49 +0100 Subject: [PATCH] RBAC: Add an endpoint to list all user permissions (#57644) * RBAC: Add an endpoint to see all user permissions Co-authored-by: Joey Orlando * Fix mock * Add feature flag * Fix merging * Return normal permissions instead of simplified ones * Fix test * Fix tests * Fix tests * Create benchtests * Split function to get basic roles * Comments * Reorg * Add two more tests to the bench * bench comment * Re-ran the test * Rename GetUsersPermissions to SearchUsersPermissions and prepare search options * Remove from model unused struct * Start adding option to get permissions by Action+Scope * Wrong import * Action and Scope * slightly tweak users permissions actionPrefix query param validation logic * Fix xor check * Lint * Account for suggeston Co-authored-by: ievaVasiljeva * Add search * Remove comment on global scope * use union all and update test to make it run on all dbs * Fix MySQL needs a space * Account for suggestion. Co-authored-by: ievaVasiljeva Co-authored-by: Joey Orlando Co-authored-by: Joey Orlando Co-authored-by: ievaVasiljeva --- pkg/api/common_test.go | 4 +- pkg/api/org_users_test.go | 9 +- pkg/services/accesscontrol/accesscontrol.go | 8 + pkg/services/accesscontrol/acimpl/service.go | 95 ++++- .../acimpl/service_bench_test.go | 210 +++++++++++ .../accesscontrol/acimpl/service_test.go | 150 ++++++++ pkg/services/accesscontrol/actest/fake.go | 34 +- pkg/services/accesscontrol/api/api.go | 40 +- pkg/services/accesscontrol/api/api_test.go | 5 +- .../accesscontrol/database/database.go | 104 ++++++ .../accesscontrol/database/database_test.go | 349 +++++++++++++++++- pkg/services/accesscontrol/mock/mock.go | 12 + pkg/services/accesscontrol/models.go | 1 + pkg/services/accesscontrol/roles.go | 4 + 14 files changed, 1003 insertions(+), 22 deletions(-) create mode 100644 pkg/services/accesscontrol/acimpl/service_bench_test.go diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index 67230445046..c9450f7267a 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -404,11 +404,11 @@ func setupHTTPServerWithCfgDb( userSvc = userMock } else { var err error - acService, err = acimpl.ProvideService(cfg, db, routeRegister, localcache.ProvideService(), featuremgmt.WithFeatures()) - require.NoError(t, err) ac = acimpl.ProvideAccessControl(cfg) userSvc, err = userimpl.ProvideService(db, nil, cfg, teamimpl.ProvideService(db, cfg), localcache.ProvideService(), quotatest.New(false, nil)) require.NoError(t, err) + acService, err = acimpl.ProvideService(cfg, db, routeRegister, localcache.ProvideService(), ac, featuremgmt.WithFeatures()) + require.NoError(t, err) } teamPermissionService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, routeRegister, db, ac, license, acService, teamService, userSvc) require.NoError(t, err) diff --git a/pkg/api/org_users_test.go b/pkg/api/org_users_test.go index 291d2c3988d..7a418591664 100644 --- a/pkg/api/org_users_test.go +++ b/pkg/api/org_users_test.go @@ -377,10 +377,11 @@ func TestGetOrgUsersAPIEndpoint_AccessControlMetadata(t *testing.T) { enableAccessControl: true, expectedCode: http.StatusOK, expectedMetadata: map[string]bool{ - "org.users:write": true, - "org.users:add": true, - "org.users:read": true, - "org.users:remove": true}, + "org.users:write": true, + "org.users:add": true, + "org.users:read": true, + "org.users:remove": true, + "users.permissions:read": true}, user: testServerAdminViewer, targetOrg: testServerAdminViewer.OrgID, }, diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index ecbdf55692d..965cd622249 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -26,6 +26,8 @@ type Service interface { registry.ProvidesUsageStats // GetUserPermissions returns user permissions with only action and scope fields set. GetUserPermissions(ctx context.Context, user *user.SignedInUser, options Options) ([]Permission, error) + // SearchUsersPermissions returns all users' permissions filtered by an action prefix + SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, options SearchOptions) (map[int64][]Permission, error) // ClearUserPermissionCache removes the permission cache entry for the given user ClearUserPermissionCache(user *user.SignedInUser) // DeleteUserPermissions removes all permissions user has in org and all permission to that user @@ -47,6 +49,12 @@ type Options struct { ReloadCache bool } +type SearchOptions struct { + ActionPrefix string // Needed for the PoC v1, it's probably going to be removed. + Action string + Scope string +} + type TeamPermissionsService interface { GetPermissions(ctx context.Context, user *user.SignedInUser, resourceID string) ([]ResourcePermission, error) SetUserPermission(ctx context.Context, orgID int64, user User, resourceID, permission string) (*ResourcePermission, error) diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index b5ac20d5ad1..d5d479c64ac 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -3,6 +3,8 @@ package acimpl import ( "context" "fmt" + "strconv" + "strings" "time" "github.com/prometheus/client_golang/prometheus" @@ -30,11 +32,11 @@ const ( ) func ProvideService(cfg *setting.Cfg, store db.DB, routeRegister routing.RouteRegister, cache *localcache.CacheService, - features *featuremgmt.FeatureManager) (*Service, error) { + accessControl accesscontrol.AccessControl, features *featuremgmt.FeatureManager) (*Service, error) { service := ProvideOSSService(cfg, database.ProvideService(store), cache, features) if !accesscontrol.IsDisabled(cfg) { - api.NewAccessControlAPI(routeRegister, service).RegisterAPIEndpoints() + api.NewAccessControlAPI(routeRegister, accessControl, service, features).RegisterAPIEndpoints() if err := accesscontrol.DeclareFixedRoles(service); err != nil { return nil, err } @@ -58,6 +60,8 @@ func ProvideOSSService(cfg *setting.Cfg, store store, cache *localcache.CacheSer type store interface { GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error) + SearchUsersPermissions(ctx context.Context, orgID int64, option accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) + GetUsersBasicRoles(ctx context.Context, orgID int64) (map[int64][]string, error) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error } @@ -244,3 +248,90 @@ func (s *Service) DeclarePluginRoles(_ context.Context, ID, name string, regs [] return nil } + +// SearchUsersPermissions returns all users' permissions filtered by action prefixes +func (s *Service) SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, + options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + // Filter ram permissions + basicPermissions := map[string][]accesscontrol.Permission{} + for role, basicRole := range s.roles { + for i := range basicRole.Permissions { + if options.ActionPrefix != "" { + if strings.HasPrefix(basicRole.Permissions[i].Action, options.ActionPrefix) { + basicPermissions[role] = append(basicPermissions[role], basicRole.Permissions[i]) + } + } + if options.Action != "" { + if basicRole.Permissions[i].Action == options.Action { + basicPermissions[role] = append(basicPermissions[role], basicRole.Permissions[i]) + } + } + } + } + + usersRoles, err := s.store.GetUsersBasicRoles(ctx, orgID) + if err != nil { + return nil, err + } + + // Get managed permissions (DB) + usersPermissions, err := s.store.SearchUsersPermissions(ctx, orgID, options) + if err != nil { + return nil, err + } + + // helper to filter out permissions the signed in users cannot see + canView := func() func(userID int64) bool { + siuPermissions, ok := user.Permissions[orgID] + if !ok { + return func(_ int64) bool { return false } + } + scopes, ok := siuPermissions[accesscontrol.ActionUsersPermissionsRead] + if !ok { + return func(_ int64) bool { return false } + } + + ids := map[int64]bool{} + for i := range scopes { + if strings.HasSuffix(scopes[i], "*") { + return func(_ int64) bool { return true } + } + parts := strings.Split(scopes[i], ":") + if len(parts) != 3 { + continue + } + id, err := strconv.ParseInt(parts[2], 10, 64) + if err != nil { + continue + } + ids[id] = true + } + + return func(userID int64) bool { return ids[userID] } + }() + + // Merge stored (DB) and basic role permissions (RAM) + // Assumes that all users with stored permissions have org roles + res := map[int64][]accesscontrol.Permission{} + for userID, roles := range usersRoles { + if !canView(userID) { + continue + } + perms := []accesscontrol.Permission{} + for i := range roles { + basicPermission, ok := basicPermissions[roles[i]] + if !ok { + continue + } + perms = append(perms, basicPermission...) + } + if dbPerms, ok := usersPermissions[userID]; ok { + perms = append(perms, dbPerms...) + } + if len(perms) > 0 { + res[userID] = perms + } + } + + return res, nil +} diff --git a/pkg/services/accesscontrol/acimpl/service_bench_test.go b/pkg/services/accesscontrol/acimpl/service_bench_test.go new file mode 100644 index 00000000000..df6660bebe0 --- /dev/null +++ b/pkg/services/accesscontrol/acimpl/service_bench_test.go @@ -0,0 +1,210 @@ +package acimpl + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/database" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/stretchr/testify/require" +) + +const batchSize = 500 + +func batch(count, size int, eachFn func(start, end int) error) error { + for i := 0; i < count; { + end := i + size + if end > count { + end = count + } + + if err := eachFn(i, end); err != nil { + return err + } + + i = end + } + + return nil +} + +func setupBenchEnv(b *testing.B, usersCount, resourceCount int) (accesscontrol.Service, *user.SignedInUser) { + now := time.Now() + sqlStore := db.InitTestDB(b) + store := database.ProvideService(sqlStore) + acService := &Service{ + cfg: setting.NewCfg(), + log: log.New("accesscontrol-test"), + registrations: accesscontrol.RegistrationList{}, + store: store, + roles: accesscontrol.BuildBasicRoleDefinitions(), + } + + // Prepare default permissions + action1 := "resources:action1" + err := acService.DeclareFixedRoles(accesscontrol.RoleRegistration{ + Role: accesscontrol.RoleDTO{Name: "fixed:test:role", Permissions: []accesscontrol.Permission{{Action: action1}}}, + Grants: []string{string(org.RoleViewer)}, + }) + require.NoError(b, err) + err = acService.RegisterFixedRoles(context.Background()) + require.NoError(b, err) + + // Prepare managed permissions + action2 := "resources:action2" + users := make([]user.User, 0, usersCount) + orgUsers := make([]org.OrgUser, 0, usersCount) + roles := make([]accesscontrol.Role, 0, usersCount) + userRoles := make([]accesscontrol.UserRole, 0, usersCount) + permissions := make([]accesscontrol.Permission, 0, resourceCount*usersCount) + for u := 1; u < usersCount+1; u++ { + users = append(users, user.User{ + ID: int64(u), + Name: fmt.Sprintf("user%v", u), + Login: fmt.Sprintf("user%v", u), + Email: fmt.Sprintf("user%v@example.org", u), + Created: now, + Updated: now, + }) + orgUsers = append(orgUsers, org.OrgUser{ + ID: int64(u), + UserID: int64(u), + OrgID: 1, + Role: org.RoleViewer, + Created: now, + Updated: now, + }) + roles = append(roles, accesscontrol.Role{ + ID: int64(u), + UID: fmt.Sprintf("managed_users_%v_permissions", u), + Name: fmt.Sprintf("managed:users:%v:permissions", u), + Version: 1, + Created: now, + Updated: now, + }) + userRoles = append(userRoles, accesscontrol.UserRole{ + ID: int64(u), + OrgID: 1, + RoleID: int64(u), + UserID: int64(u), + Created: now, + }) + + for r := 1; r < resourceCount+1; r++ { + permissions = append(permissions, accesscontrol.Permission{ + RoleID: int64(u), + Action: action2, + Scope: fmt.Sprintf("resources:id:%v", r), + Created: now, + Updated: now, + }) + } + } + + // Populate store + if err := batch(len(roles), batchSize, func(start, end int) error { + err := sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + if _, err := sess.Insert(users[start:end]); err != nil { + return err + } + if _, err := sess.Insert(orgUsers[start:end]); err != nil { + return err + } + if _, err := sess.Insert(roles[start:end]); err != nil { + return err + } + _, err := sess.Insert(userRoles[start:end]) + return err + }) + return err + }); err != nil { + require.NoError(b, err, "could not insert users and roles") + return nil, nil + } + if err := batch(len(permissions), batchSize, func(start, end int) error { + err := sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error { + _, err := sess.Insert(permissions[start:end]) + return err + }) + return err + }); err != nil { + require.NoError(b, err, "could not insert permissions") + return nil, nil + } + + // Allow signed in user to view all users permissions in the worst way + userPermissions := map[string][]string{} + for u := 1; u < usersCount+1; u++ { + userPermissions[accesscontrol.ActionUsersPermissionsRead] = + append(userPermissions[accesscontrol.ActionUsersPermissionsRead], fmt.Sprintf("users:id:%v", u)) + } + return acService, &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: userPermissions}} +} + +func benchSearchUsersPermissions(b *testing.B, usersCount, resourceCount int) { + acService, siu := setupBenchEnv(b, usersCount, resourceCount) + b.ResetTimer() + + for n := 0; n < b.N; n++ { + usersPermissions, err := acService.SearchUsersPermissions(context.Background(), siu, 1, accesscontrol.SearchOptions{ActionPrefix: "resources:"}) + require.NoError(b, err) + require.Len(b, usersPermissions, usersCount) + for _, permissions := range usersPermissions { + // action1 on all resource + action2 + require.Len(b, permissions, resourceCount+1) + } + } +} + +// Lots of resources +func BenchmarkSearchUsersPermissions_10_1K(b *testing.B) { benchSearchUsersPermissions(b, 10, 1000) } // ~0.047s/op +func BenchmarkSearchUsersPermissions_10_10K(b *testing.B) { benchSearchUsersPermissions(b, 10, 10000) } // ~0.5s/op +func BenchmarkSearchUsersPermissions_10_100K(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 10, 100000) +} // ~4.6s/op +func BenchmarkSearchUsersPermissions_10_1M(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 10, 1000000) +} // ~55.36s/op + +// Lots of users (most probable case) +func BenchmarkSearchUsersPermissions_1K_10(b *testing.B) { benchSearchUsersPermissions(b, 1000, 10) } // ~0.056s/op +func BenchmarkSearchUsersPermissions_10K_10(b *testing.B) { benchSearchUsersPermissions(b, 10000, 10) } // ~0.58s/op +func BenchmarkSearchUsersPermissions_100K_10(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 100000, 10) +} // ~6.21s/op +func BenchmarkSearchUsersPermissions_1M_10(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 1000000, 10) +} // ~57s/op + +// Lots of both +func BenchmarkSearchUsersPermissions_10K_100(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 10000, 100) +} // ~1.45s/op +func BenchmarkSearchUsersPermissions_10K_1K(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchSearchUsersPermissions(b, 10000, 1000) +} // ~50s/op diff --git a/pkg/services/accesscontrol/acimpl/service_test.go b/pkg/services/accesscontrol/acimpl/service_test.go index 09bd7435afa..fd9d6bb24a8 100644 --- a/pkg/services/accesscontrol/acimpl/service_test.go +++ b/pkg/services/accesscontrol/acimpl/service_test.go @@ -12,8 +12,10 @@ import ( "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/accesscontrol/database" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/user" @@ -65,6 +67,7 @@ func TestUsageMetrics(t *testing.T) { db.InitTestDB(t), routing.NewRouteRegister(), localcache.ProvideService(), + actest.FakeAccessControl{}, featuremgmt.WithFeatures(), ) require.NoError(t, errInitAc) @@ -373,6 +376,153 @@ func TestService_RegisterFixedRoles(t *testing.T) { } } +func TestService_SearchUsersPermissions(t *testing.T) { + searchOption := accesscontrol.SearchOptions{ActionPrefix: "teams"} + ctx := context.Background() + listAllPerms := map[string][]string{accesscontrol.ActionUsersPermissionsRead: {"users:*"}} + listSomePerms := map[string][]string{accesscontrol.ActionUsersPermissionsRead: {"users:id:2"}} + tests := []struct { + name string + siuPermissions map[string][]string + ramRoles map[string]*accesscontrol.RoleDTO // BasicRole => RBAC BasicRole + storedPerms map[int64][]accesscontrol.Permission // UserID => Permissions + storedRoles map[int64][]string // UserID => Roles + want map[int64][]accesscontrol.Permission + wantErr bool + }{ + { + name: "ram only", + siuPermissions: listAllPerms, + ramRoles: map[string]*accesscontrol.RoleDTO{ + string(roletype.RoleAdmin): {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + }}, + accesscontrol.RoleGrafanaAdmin: {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}, + }}, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleEditor)}, + 2: {string(roletype.RoleAdmin), accesscontrol.RoleGrafanaAdmin}, + }, + want: map[int64][]accesscontrol.Permission{ + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + { + name: "stored only", + siuPermissions: listAllPerms, + storedPerms: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleEditor)}, + 2: {string(roletype.RoleAdmin), accesscontrol.RoleGrafanaAdmin}, + }, + want: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + { + name: "ram and stored", + siuPermissions: listAllPerms, + ramRoles: map[string]*accesscontrol.RoleDTO{ + string(roletype.RoleAdmin): {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + }}, + accesscontrol.RoleGrafanaAdmin: {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}, + }}, + }, + storedPerms: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}}, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleEditor)}, + 2: {string(roletype.RoleAdmin), accesscontrol.RoleGrafanaAdmin}, + }, + want: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: "teams:*"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + { + name: "view permission on subset of users only", + siuPermissions: listSomePerms, + ramRoles: map[string]*accesscontrol.RoleDTO{ + accesscontrol.RoleGrafanaAdmin: {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}, + }}, + }, + storedPerms: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}}, + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}}, + }, + storedRoles: map[int64][]string{ + 1: {string(roletype.RoleEditor)}, + 2: {accesscontrol.RoleGrafanaAdmin}, + }, + want: map[int64][]accesscontrol.Permission{ + 2: {{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + { + name: "check action filter on RAM permissions works correctly", + siuPermissions: listAllPerms, + ramRoles: map[string]*accesscontrol.RoleDTO{ + accesscontrol.RoleGrafanaAdmin: {Permissions: []accesscontrol.Permission{ + {Action: accesscontrol.ActionUsersCreate}, + {Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}, + }}, + }, + storedRoles: map[int64][]string{1: {accesscontrol.RoleGrafanaAdmin}}, + want: map[int64][]accesscontrol.Permission{ + 1: {{Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:*"}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ac := setupTestEnv(t) + + ac.roles = tt.ramRoles + ac.store = actest.FakeStore{ + ExpectedUsersPermissions: tt.storedPerms, + ExpectedUsersRoles: tt.storedRoles, + } + + siu := &user.SignedInUser{OrgID: 2, Permissions: map[int64]map[string][]string{2: tt.siuPermissions}} + got, err := ac.SearchUsersPermissions(ctx, siu, 2, searchOption) + if tt.wantErr { + require.NotNil(t, err) + return + } + require.Nil(t, err) + + require.Len(t, got, len(tt.want), "expected more users permissions") + for userID, wantPerm := range tt.want { + gotPerm, ok := got[userID] + require.True(t, ok, "expected permissions for user", userID) + + require.ElementsMatch(t, gotPerm, wantPerm) + } + }) + } +} + func TestPermissionCacheKey(t *testing.T) { testcases := []struct { name string diff --git a/pkg/services/accesscontrol/actest/fake.go b/pkg/services/accesscontrol/actest/fake.go index d62f7a9424b..3f6bad09e01 100644 --- a/pkg/services/accesscontrol/actest/fake.go +++ b/pkg/services/accesscontrol/actest/fake.go @@ -11,9 +11,10 @@ var _ accesscontrol.Service = new(FakeService) var _ accesscontrol.RoleRegistry = new(FakeService) type FakeService struct { - ExpectedErr error - ExpectedDisabled bool - ExpectedPermissions []accesscontrol.Permission + ExpectedErr error + ExpectedDisabled bool + ExpectedPermissions []accesscontrol.Permission + ExpectedUsersPermissions map[int64][]accesscontrol.Permission } func (f FakeService) GetUsageStats(ctx context.Context) map[string]interface{} { @@ -24,6 +25,10 @@ func (f FakeService) GetUserPermissions(ctx context.Context, user *user.SignedIn return f.ExpectedPermissions, f.ExpectedErr } +func (f FakeService) SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + return f.ExpectedUsersPermissions, f.ExpectedErr +} + func (f FakeService) ClearUserPermissionCache(user *user.SignedInUser) {} func (f FakeService) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error { @@ -60,3 +65,26 @@ func (f FakeAccessControl) RegisterScopeAttributeResolver(prefix string, resolve func (f FakeAccessControl) IsDisabled() bool { return f.ExpectedDisabled } + +type FakeStore struct { + ExpectedUserPermissions []accesscontrol.Permission + ExpectedUsersPermissions map[int64][]accesscontrol.Permission + ExpectedUsersRoles map[int64][]string + ExpectedErr error +} + +func (f FakeStore) GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]accesscontrol.Permission, error) { + return f.ExpectedUserPermissions, f.ExpectedErr +} + +func (f FakeStore) SearchUsersPermissions(ctx context.Context, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + return f.ExpectedUsersPermissions, f.ExpectedErr +} + +func (f FakeStore) GetUsersBasicRoles(ctx context.Context, orgID int64) (map[int64][]string, error) { + return f.ExpectedUsersRoles, f.ExpectedErr +} + +func (f FakeStore) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error { + return f.ExpectedErr +} diff --git a/pkg/services/accesscontrol/api/api.go b/pkg/services/accesscontrol/api/api.go index 04e90e276c4..5c06a9ef239 100644 --- a/pkg/services/accesscontrol/api/api.go +++ b/pkg/services/accesscontrol/api/api.go @@ -8,25 +8,36 @@ import ( "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" ) -func NewAccessControlAPI(router routing.RouteRegister, service ac.Service) *AccessControlAPI { +func NewAccessControlAPI(router routing.RouteRegister, accesscontrol ac.AccessControl, service ac.Service, + features *featuremgmt.FeatureManager) *AccessControlAPI { return &AccessControlAPI{ RouteRegister: router, Service: service, + AccessControl: accesscontrol, + features: features, } } type AccessControlAPI struct { Service ac.Service + AccessControl ac.AccessControl RouteRegister routing.RouteRegister + features *featuremgmt.FeatureManager } func (api *AccessControlAPI) RegisterAPIEndpoints() { + authorize := ac.Middleware(api.AccessControl) // Users api.RouteRegister.Group("/api/access-control", func(rr routing.RouteRegister) { rr.Get("/user/actions", middleware.ReqSignedIn, routing.Wrap(api.getUserActions)) rr.Get("/user/permissions", middleware.ReqSignedIn, routing.Wrap(api.getUserPermissions)) + if api.features.IsEnabled(featuremgmt.FlagAccessControlOnCall) { + rr.Get("/users/permissions/search", authorize(middleware.ReqSignedIn, + ac.EvalPermission(ac.ActionUsersPermissionsRead)), routing.Wrap(api.SearchUsersPermissions)) + } }) } @@ -53,3 +64,30 @@ func (api *AccessControlAPI) getUserPermissions(c *models.ReqContext) response.R return response.JSON(http.StatusOK, ac.GroupScopesByAction(permissions)) } + +// GET /api/access-control/users/permissions +func (api *AccessControlAPI) SearchUsersPermissions(c *models.ReqContext) response.Response { + searchOptions := ac.SearchOptions{ + ActionPrefix: c.Query("actionPrefix"), + Action: c.Query("action"), + Scope: c.Query("scope"), + } + + // Validate inputs + if (searchOptions.ActionPrefix != "") == (searchOptions.Action != "") { + return response.JSON(http.StatusBadRequest, "provide one of 'action' or 'actionPrefix'") + } + + // Compute metadata + permissions, err := api.Service.SearchUsersPermissions(c.Req.Context(), c.SignedInUser, c.OrgID, searchOptions) + if err != nil { + return response.Error(http.StatusInternalServerError, "could not get org user permissions", err) + } + + permsByAction := map[int64]map[string][]string{} + for userID, userPerms := range permissions { + permsByAction[userID] = ac.GroupScopesByAction(userPerms) + } + + return response.JSON(http.StatusOK, permsByAction) +} diff --git a/pkg/services/accesscontrol/api/api_test.go b/pkg/services/accesscontrol/api/api_test.go index 3be8ee82c30..13a2f6161fc 100644 --- a/pkg/services/accesscontrol/api/api_test.go +++ b/pkg/services/accesscontrol/api/api_test.go @@ -9,6 +9,7 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web/webtest" @@ -38,7 +39,7 @@ func TestAPI_getUserActions(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { acSvc := actest.FakeService{ExpectedPermissions: tt.permissions} - api := NewAccessControlAPI(routing.NewRouteRegister(), acSvc) + api := NewAccessControlAPI(routing.NewRouteRegister(), actest.FakeAccessControl{}, acSvc, featuremgmt.WithFeatures()) api.RegisterAPIEndpoints() server := webtest.NewServer(t, api.RouteRegister) @@ -91,7 +92,7 @@ func TestAPI_getUserPermissions(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { acSvc := actest.FakeService{ExpectedPermissions: tt.permissions} - api := NewAccessControlAPI(routing.NewRouteRegister(), acSvc) + api := NewAccessControlAPI(routing.NewRouteRegister(), actest.FakeAccessControl{}, acSvc, featuremgmt.WithFeatures()) api.RegisterAPIEndpoints() server := webtest.NewServer(t, api.RouteRegister) diff --git a/pkg/services/accesscontrol/database/database.go b/pkg/services/accesscontrol/database/database.go index a53674c9bd5..eed62580b2b 100644 --- a/pkg/services/accesscontrol/database/database.go +++ b/pkg/services/accesscontrol/database/database.go @@ -55,6 +55,110 @@ func (s *AccessControlStore) GetUserPermissions(ctx context.Context, query acces return result, err } +// SearchUsersPermissions returns the list of user permissions indexed by UserID +func (s *AccessControlStore) SearchUsersPermissions(ctx context.Context, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + type UserRBACPermission struct { + UserID int64 `xorm:"user_id"` + Action string `xorm:"action"` + Scope string `xorm:"scope"` + } + dbPerms := make([]UserRBACPermission, 0) + if err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + // Find permissions + q := ` + SELECT + user_id, + action, + scope + FROM ( + SELECT ur.user_id, ur.org_id, p.action, p.scope + FROM permission AS p + INNER JOIN user_role AS ur on ur.role_id = p.role_id + UNION ALL + SELECT tm.user_id, tr.org_id, p.action, p.scope + FROM permission AS p + INNER JOIN team_role AS tr ON tr.role_id = p.role_id + INNER JOIN team_member AS tm ON tm.team_id = tr.team_id + UNION ALL + SELECT ou.user_id, br.org_id, p.action, p.scope + FROM permission AS p + INNER JOIN builtin_role AS br ON br.role_id = p.role_id + INNER JOIN org_user AS ou ON ou.role = br.role + UNION ALL + SELECT sa.user_id, br.org_id, p.action, p.scope + FROM permission AS p + INNER JOIN builtin_role AS br ON br.role_id = p.role_id + INNER JOIN ( + SELECT u.id AS user_id + FROM ` + s.sql.GetDialect().Quote("user") + ` AS u WHERE u.is_admin + ) AS sa ON 1 = 1 + WHERE br.role = ? + ) AS up + WHERE (org_id = ? OR org_id = ?) + ` + params := []interface{}{accesscontrol.RoleGrafanaAdmin, accesscontrol.GlobalOrgID, orgID} + + if options.ActionPrefix != "" { + q += ` AND action LIKE ?` + params = append(params, options.ActionPrefix+"%") + } + if options.Action != "" { + q += ` AND action = ?` + params = append(params, options.Action) + } + if options.Scope != "" { + q += ` AND scope = ?` + params = append(params, options.Scope) + } + + return sess.SQL(q, params...). + Find(&dbPerms) + }); err != nil { + return nil, err + } + + mapped := map[int64][]accesscontrol.Permission{} + for i := range dbPerms { + mapped[dbPerms[i].UserID] = append(mapped[dbPerms[i].UserID], accesscontrol.Permission{Action: dbPerms[i].Action, Scope: dbPerms[i].Scope}) + } + + return mapped, nil +} + +// GetUsersBasicRoles returns the list of user basic roles (Admin, Editor, Viewer, Grafana Admin) indexed by UserID +func (s *AccessControlStore) GetUsersBasicRoles(ctx context.Context, orgID int64) (map[int64][]string, error) { + type UserOrgRole struct { + UserID int64 `xorm:"id"` + OrgRole string `xorm:"role"` + IsAdmin bool `xorm:"is_admin"` + } + dbRoles := make([]UserOrgRole, 0) + if err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + // Find roles + q := ` + SELECT u.id, ou.role, u.is_admin + FROM ` + s.sql.GetDialect().Quote("user") + ` AS u + LEFT JOIN org_user AS ou ON u.id = ou.user_id + WHERE u.is_admin OR ou.org_id = ? + ` + + return sess.SQL(q, orgID).Find(&dbRoles) + }); err != nil { + return nil, err + } + + roles := map[int64][]string{} + for i := range dbRoles { + if dbRoles[i].OrgRole != "" { + roles[dbRoles[i].UserID] = []string{dbRoles[i].OrgRole} + } + if dbRoles[i].IsAdmin { + roles[dbRoles[i].UserID] = append(roles[dbRoles[i].UserID], accesscontrol.RoleGrafanaAdmin) + } + } + return roles, nil +} + func (s *AccessControlStore) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error { err := s.sql.WithDbSession(ctx, func(sess *db.Session) error { roleDeleteQuery := "DELETE FROM user_role WHERE user_id = ?" diff --git a/pkg/services/accesscontrol/database/database_test.go b/pkg/services/accesscontrol/database/database_test.go index 8ea2c933d82..57c482c0f91 100644 --- a/pkg/services/accesscontrol/database/database_test.go +++ b/pkg/services/accesscontrol/database/database_test.go @@ -2,20 +2,24 @@ package database import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" rs "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" ) type getUserPermissionsTestCase struct { @@ -82,7 +86,7 @@ func TestAccessControlStore_GetUserPermissions(t *testing.T) { } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - store, permissionStore, sql, teamSvc := setupTestEnv(t) + store, permissionStore, sql, teamSvc, _ := setupTestEnv(t) user, team := createUserAndTeam(t, sql, teamSvc, tt.orgID) @@ -145,7 +149,7 @@ func TestAccessControlStore_GetUserPermissions(t *testing.T) { func TestAccessControlStore_DeleteUserPermissions(t *testing.T) { t.Run("expect permissions in all orgs to be deleted", func(t *testing.T) { - store, permissionsStore, sql, teamSvc := setupTestEnv(t) + store, permissionsStore, sql, teamSvc, _ := setupTestEnv(t) user, _ := createUserAndTeam(t, sql, teamSvc, 1) // generate permissions in org 1 @@ -185,7 +189,7 @@ func TestAccessControlStore_DeleteUserPermissions(t *testing.T) { }) t.Run("expect permissions in org 1 to be deleted", func(t *testing.T) { - store, permissionsStore, sql, teamSvc := setupTestEnv(t) + store, permissionsStore, sql, teamSvc, _ := setupTestEnv(t) user, _ := createUserAndTeam(t, sql, teamSvc, 1) // generate permissions in org 1 @@ -225,10 +229,10 @@ func TestAccessControlStore_DeleteUserPermissions(t *testing.T) { }) } -func createUserAndTeam(t *testing.T, sql *sqlstore.SQLStore, teamSvc team.Service, orgID int64) (*user.User, models.Team) { +func createUserAndTeam(t *testing.T, userSrv user.Service, teamSvc team.Service, orgID int64) (*user.User, models.Team) { t.Helper() - user, err := sql.CreateUser(context.Background(), user.CreateUserCommand{ + user, err := userSrv.Create(context.Background(), &user.CreateUserCommand{ Login: "user", OrgID: orgID, }) @@ -243,10 +247,339 @@ func createUserAndTeam(t *testing.T, sql *sqlstore.SQLStore, teamSvc team.Servic return user, team } -func setupTestEnv(t testing.TB) (*AccessControlStore, rs.Store, *sqlstore.SQLStore, team.Service) { +type helperServices struct { + userSvc user.Service + teamSvc team.Service + orgSvc org.Service +} + +type testUser struct { + orgRole org.RoleType + isAdmin bool +} + +type dbUser struct { + userID int64 + teamID int64 +} + +func createUsersAndTeams(t *testing.T, svcs helperServices, orgID int64, users []testUser) []dbUser { + t.Helper() + res := []dbUser{} + + for i := range users { + user, err := svcs.userSvc.Create(context.Background(), &user.CreateUserCommand{ + Login: fmt.Sprintf("user%v", i+1), + OrgID: orgID, + IsAdmin: users[i].isAdmin, + }) + require.NoError(t, err) + + // User is not member of the org + if users[i].orgRole == "" { + err = svcs.orgSvc.RemoveOrgUser(context.Background(), + &org.RemoveOrgUserCommand{OrgID: orgID, UserID: user.ID}) + require.NoError(t, err) + + res = append(res, dbUser{userID: user.ID}) + continue + } + + team, err := svcs.teamSvc.CreateTeam(fmt.Sprintf("team%v", i+1), "", orgID) + require.NoError(t, err) + + err = svcs.teamSvc.AddTeamMember(user.ID, orgID, team.Id, false, models.PERMISSION_VIEW) + require.NoError(t, err) + + err = svcs.orgSvc.UpdateOrgUser(context.Background(), + &org.UpdateOrgUserCommand{Role: users[i].orgRole, OrgID: orgID, UserID: user.ID}) + require.NoError(t, err) + + res = append(res, dbUser{userID: user.ID, teamID: team.Id}) + } + + return res +} + +func setupTestEnv(t testing.TB) (*AccessControlStore, rs.Store, user.Service, team.Service, org.Service) { sql, cfg := db.InitTestDBwithCfg(t) acstore := ProvideService(sql) permissionStore := rs.NewStore(sql) teamService := teamimpl.ProvideService(sql, cfg) - return acstore, permissionStore, sql, teamService + orgService, err := orgimpl.ProvideService(sql, cfg, quotatest.New(false, nil)) + require.NoError(t, err) + userService, err := userimpl.ProvideService(sql, orgService, cfg, teamService, localcache.ProvideService(), quotatest.New(false, nil)) + require.NoError(t, err) + return acstore, permissionStore, userService, teamService, orgService +} + +func TestIntegrationAccessControlStore_SearchUsersPermissions(t *testing.T) { + ctx := context.Background() + readTeamPerm := func(teamID string) rs.SetResourcePermissionCommand { + return rs.SetResourcePermissionCommand{ + Actions: []string{"teams:read"}, + Resource: "teams", + ResourceAttribute: "id", + ResourceID: teamID, + } + } + writeTeamPerm := func(teamID string) rs.SetResourcePermissionCommand { + return rs.SetResourcePermissionCommand{ + Actions: []string{"teams:read", "teams:write"}, + Resource: "teams", + ResourceAttribute: "id", + ResourceID: teamID, + } + } + readDashPerm := func(dashUID string) rs.SetResourcePermissionCommand { + return rs.SetResourcePermissionCommand{ + Actions: []string{"dashboards:read"}, + Resource: "dashboards", + ResourceAttribute: "uid", + ResourceID: dashUID, + } + } + tests := []struct { + name string + users []testUser + permCmds []rs.SetResourcePermissionsCommand + options accesscontrol.SearchOptions + wantPerm map[int64][]accesscontrol.Permission + wantErr bool + }{ + { + name: "user assignment by actionPrefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + { + name: "users assignment by actionPrefix", + users: []testUser{ + {orgRole: org.RoleAdmin, isAdmin: false}, + {orgRole: org.RoleEditor, isAdmin: false}, + }, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: writeTeamPerm("1")}, + {User: accesscontrol.User{ID: 2, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("2")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{ + 1: {{Action: "teams:read", Scope: "teams:id:1"}, {Action: "teams:write", Scope: "teams:id:1"}}, + 2: {{Action: "teams:read", Scope: "teams:id:2"}}, + }, + }, + { + name: "team assignment by actionPrefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{{TeamID: 1, SetResourcePermissionCommand: readTeamPerm("1")}}, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + { + name: "basic role assignment by actionPrefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {BuiltinRole: string(org.RoleAdmin), SetResourcePermissionCommand: readTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + { + name: "server admin assignment by actionPrefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: true}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + { + name: "all assignments by actionPrefix", + users: []testUser{ + {orgRole: org.RoleAdmin, isAdmin: true}, + {orgRole: org.RoleEditor, isAdmin: false}, + }, + permCmds: []rs.SetResourcePermissionsCommand{ + // User assignments + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 2, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("2")}, + // Team assignments + {TeamID: 1, SetResourcePermissionCommand: readTeamPerm("10")}, + {TeamID: 2, SetResourcePermissionCommand: readTeamPerm("20")}, + // Basic Assignments + {BuiltinRole: string(org.RoleAdmin), SetResourcePermissionCommand: readTeamPerm("100")}, + {BuiltinRole: string(org.RoleEditor), SetResourcePermissionCommand: readTeamPerm("200")}, + // Server Admin Assignment + {BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readTeamPerm("1000")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{ + 1: {{Action: "teams:read", Scope: "teams:id:1"}, {Action: "teams:read", Scope: "teams:id:10"}, + {Action: "teams:read", Scope: "teams:id:100"}, {Action: "teams:read", Scope: "teams:id:1000"}}, + 2: {{Action: "teams:read", Scope: "teams:id:2"}, {Action: "teams:read", Scope: "teams:id:20"}, + {Action: "teams:read", Scope: "teams:id:200"}}, + }, + }, + { + name: "filter permissions by action prefix", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: true}}, + permCmds: []rs.SetResourcePermissionsCommand{ + // User assignments + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readDashPerm("d1")}, + // Team assignments + {TeamID: 1, SetResourcePermissionCommand: readTeamPerm("10")}, + {TeamID: 1, SetResourcePermissionCommand: readDashPerm("d10")}, + // Basic Assignments + {BuiltinRole: string(org.RoleAdmin), SetResourcePermissionCommand: readTeamPerm("100")}, + {BuiltinRole: string(org.RoleAdmin), SetResourcePermissionCommand: readDashPerm("d100")}, + // Server Admin Assignment + {BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readTeamPerm("1000")}, + {BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readDashPerm("d1000")}, + }, + options: accesscontrol.SearchOptions{ActionPrefix: "teams:"}, + wantPerm: map[int64][]accesscontrol.Permission{ + 1: {{Action: "teams:read", Scope: "teams:id:1"}, {Action: "teams:read", Scope: "teams:id:10"}, + {Action: "teams:read", Scope: "teams:id:100"}, {Action: "teams:read", Scope: "teams:id:1000"}}, + }, + }, + { + name: "include not org member server admin permissions by actionPrefix", + // Three users, one member, one not member but Server Admin, one not member and not server admin + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}, {isAdmin: true}, {}}, + permCmds: []rs.SetResourcePermissionsCommand{{BuiltinRole: accesscontrol.RoleGrafanaAdmin, SetResourcePermissionCommand: readTeamPerm("1")}}, + wantPerm: map[int64][]accesscontrol.Permission{ + 2: {{Action: "teams:read", Scope: "teams:id:1"}}, + }, + }, + { + name: "user assignment by action", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("2")}, + }, + options: accesscontrol.SearchOptions{Action: "teams:read"}, + wantPerm: map[int64][]accesscontrol.Permission{1: { + {Action: "teams:read", Scope: "teams:id:1"}, + {Action: "teams:read", Scope: "teams:id:2"}}, + }, + }, + { + name: "user assignment by scope", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: writeTeamPerm("1")}, + }, + options: accesscontrol.SearchOptions{Scope: "teams:id:1"}, + wantPerm: map[int64][]accesscontrol.Permission{1: { + {Action: "teams:read", Scope: "teams:id:1"}, + {Action: "teams:write", Scope: "teams:id:1"}, + }}, + }, + { + name: "user assignment by action and scope", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + permCmds: []rs.SetResourcePermissionsCommand{ + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("1")}, + {User: accesscontrol.User{ID: 1, IsExternal: false}, SetResourcePermissionCommand: readTeamPerm("2")}, + }, + options: accesscontrol.SearchOptions{Action: "teams:read", Scope: "teams:id:1"}, + wantPerm: map[int64][]accesscontrol.Permission{1: {{Action: "teams:read", Scope: "teams:id:1"}}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + acStore, permissionsStore, userSvc, teamSvc, orgSvc := setupTestEnv(t) + dbUsers := createUsersAndTeams(t, helperServices{userSvc, teamSvc, orgSvc}, 1, tt.users) + + // Switch userID and TeamID by the real stored ones + for i := range tt.permCmds { + if tt.permCmds[i].User.ID != 0 { + tt.permCmds[i].User.ID = dbUsers[tt.permCmds[i].User.ID-1].userID + } + if tt.permCmds[i].TeamID != 0 { + tt.permCmds[i].TeamID = dbUsers[tt.permCmds[i].TeamID-1].teamID + } + } + _, err := permissionsStore.SetResourcePermissions(ctx, 1, tt.permCmds, rs.ResourceHooks{}) + require.NoError(t, err) + + // Test + dbPermissions, err := acStore.SearchUsersPermissions(ctx, 1, tt.options) + if tt.wantErr { + require.NotNil(t, err) + return + } + require.Nil(t, err) + require.Len(t, dbPermissions, len(tt.wantPerm)) + + for userID, expectedUserPerms := range tt.wantPerm { + dbUserPerms, ok := dbPermissions[dbUsers[userID-1].userID] + require.True(t, ok, "expected permissions for user", userID) + require.ElementsMatch(t, expectedUserPerms, dbUserPerms) + } + }) + } +} + +func TestAccessControlStore_GetUsersBasicRoles(t *testing.T) { + ctx := context.Background() + tests := []struct { + name string + users []testUser + wantRoles map[int64][]string + wantErr bool + }{ + { + name: "user with basic role", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}}, + wantRoles: map[int64][]string{1: {string(org.RoleAdmin)}}, + }, + { + name: "one admin, one editor", + users: []testUser{ + {orgRole: org.RoleAdmin, isAdmin: false}, + {orgRole: org.RoleEditor, isAdmin: false}, + }, + wantRoles: map[int64][]string{ + 1: {string(org.RoleAdmin)}, + 2: {string(org.RoleEditor)}, + }, + }, + { + name: "one org member, one not member but Server Admin, one not member and not server admin", + users: []testUser{{orgRole: org.RoleAdmin, isAdmin: false}, {isAdmin: true}, {}}, + wantRoles: map[int64][]string{ + 1: {string(org.RoleAdmin)}, + 2: {accesscontrol.RoleGrafanaAdmin}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + acStore, _, userSvc, teamSvc, orgSvc := setupTestEnv(t) + dbUsers := createUsersAndTeams(t, helperServices{userSvc, teamSvc, orgSvc}, 1, tt.users) + + // Test + dbRoles, err := acStore.GetUsersBasicRoles(ctx, 1) + if tt.wantErr { + require.NotNil(t, err) + return + } + require.Nil(t, err) + require.Len(t, dbRoles, len(tt.wantRoles)) + + for userID, expectedUserRoles := range tt.wantRoles { + dbUserRoles, ok := dbRoles[dbUsers[userID-1].userID] + require.True(t, ok, "expected organization role for user", userID) + require.ElementsMatch(t, expectedUserRoles, dbUserRoles) + } + }) + } } diff --git a/pkg/services/accesscontrol/mock/mock.go b/pkg/services/accesscontrol/mock/mock.go index 663902e95ca..c1f3e944b2f 100644 --- a/pkg/services/accesscontrol/mock/mock.go +++ b/pkg/services/accesscontrol/mock/mock.go @@ -28,6 +28,7 @@ type Calls struct { RegisterFixedRoles []interface{} RegisterAttributeScopeResolver []interface{} DeleteUserPermissions []interface{} + SearchUsersPermissions []interface{} } type Mock struct { @@ -52,6 +53,7 @@ type Mock struct { RegisterFixedRolesFunc func() error RegisterScopeAttributeResolverFunc func(string, accesscontrol.ScopeAttributeResolver) DeleteUserPermissionsFunc func(context.Context, int64) error + SearchUsersPermissionsFunc func(context.Context, *user.SignedInUser, int64, accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) scopeResolvers accesscontrol.Resolvers } @@ -212,3 +214,13 @@ func (m *Mock) DeleteUserPermissions(ctx context.Context, orgID, userID int64) e } return nil } + +// GetSimplifiedUsersPermissions returns all users' permissions filtered by an action prefix +func (m *Mock) SearchUsersPermissions(ctx context.Context, user *user.SignedInUser, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) { + m.Calls.SearchUsersPermissions = append(m.Calls.SearchUsersPermissions, []interface{}{ctx, user, orgID, options}) + // Use override if provided + if m.SearchUsersPermissionsFunc != nil { + return m.SearchUsersPermissionsFunc(ctx, user, orgID, options) + } + return nil, nil +} diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 143e9f8845a..e32bb26a2b7 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -309,6 +309,7 @@ const ( ActionUsersLogout = "users:logout" ActionUsersQuotasList = "users.quotas:read" ActionUsersQuotasUpdate = "users.quotas:write" + ActionUsersPermissionsRead = "users.permissions:read" // Org actions ActionOrgsRead = "orgs:read" diff --git a/pkg/services/accesscontrol/roles.go b/pkg/services/accesscontrol/roles.go index e906f9f9c11..67beeb00fb3 100644 --- a/pkg/services/accesscontrol/roles.go +++ b/pkg/services/accesscontrol/roles.go @@ -71,6 +71,10 @@ var ( Action: ActionOrgUsersRead, Scope: ScopeUsersAll, }, + { + Action: ActionUsersPermissionsRead, + Scope: ScopeUsersAll, + }, }, }