From 20bd591bea189de35dfbae38c099dd9710a1178a Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Mon, 22 Mar 2021 15:22:48 +0300 Subject: [PATCH] Access control: Basic structure and functionality behind feature toggle (#31893) Co-authored-by: Alexander Zobnin Co-authored-by: Emil Tullstedt Co-authored-by: Arve Knudsen Co-authored-by: Marcus Efraimsson --- pkg/api/http_server.go | 20 +- pkg/models/org_user.go | 11 + pkg/services/accesscontrol/accesscontrol.go | 44 ++ .../accesscontrol/database/common_test.go | 57 ++ .../accesscontrol/database/database.go | 696 ++++++++++++++++++ .../database/database_bench_test.go | 65 ++ .../accesscontrol/database/database_mig.go | 118 +++ .../accesscontrol/database/database_test.go | 387 ++++++++++ .../accesscontrol/manager/evaluator.go | 74 ++ pkg/services/accesscontrol/manager/manager.go | 53 ++ .../accesscontrol/manager/manager_test.go | 118 +++ .../accesscontrol/middleware/middleware.go | 46 ++ pkg/services/accesscontrol/models.go | 188 +++++ pkg/services/accesscontrol/seeder/seeder.go | 219 ++++++ .../accesscontrol/seeder/seeder_test.go | 156 ++++ pkg/services/accesscontrol/testing/common.go | 121 +++ .../accesscontrol/testing/common_bench.go | 109 +++ scripts/benchmark-access-control.sh | 84 +++ 18 files changed, 2556 insertions(+), 10 deletions(-) create mode 100644 pkg/services/accesscontrol/accesscontrol.go create mode 100644 pkg/services/accesscontrol/database/common_test.go create mode 100644 pkg/services/accesscontrol/database/database.go create mode 100644 pkg/services/accesscontrol/database/database_bench_test.go create mode 100644 pkg/services/accesscontrol/database/database_mig.go create mode 100644 pkg/services/accesscontrol/database/database_test.go create mode 100644 pkg/services/accesscontrol/manager/evaluator.go create mode 100644 pkg/services/accesscontrol/manager/manager.go create mode 100644 pkg/services/accesscontrol/manager/manager_test.go create mode 100644 pkg/services/accesscontrol/middleware/middleware.go create mode 100644 pkg/services/accesscontrol/models.go create mode 100644 pkg/services/accesscontrol/seeder/seeder.go create mode 100644 pkg/services/accesscontrol/seeder/seeder_test.go create mode 100644 pkg/services/accesscontrol/testing/common.go create mode 100644 pkg/services/accesscontrol/testing/common_bench.go create mode 100755 scripts/benchmark-access-control.sh diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 010da8910f3..188bfc2c5dd 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -13,13 +13,9 @@ import ( "strings" "sync" - "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/services/alerting" - "github.com/grafana/grafana/pkg/services/live" - "github.com/grafana/grafana/pkg/services/search" - "github.com/grafana/grafana/pkg/services/shorturls" - "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/tsdb" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + macaron "gopkg.in/macaron.v1" "github.com/grafana/grafana/pkg/api/routing" httpstatic "github.com/grafana/grafana/pkg/api/static" @@ -30,24 +26,28 @@ import ( "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" _ "github.com/grafana/grafana/pkg/plugins/backendplugin/manager" "github.com/grafana/grafana/pkg/plugins/plugindashboards" "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/contexthandler" "github.com/grafana/grafana/pkg/services/datasourceproxy" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/hooks" "github.com/grafana/grafana/pkg/services/librarypanels" + "github.com/grafana/grafana/pkg/services/live" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/rendering" + "github.com/grafana/grafana/pkg/services/search" + "github.com/grafana/grafana/pkg/services/shorturls" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/tsdb" "github.com/grafana/grafana/pkg/util/errutil" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" - macaron "gopkg.in/macaron.v1" ) func init() { diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index d84e3e92378..e3823f880a1 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -38,6 +38,17 @@ func (r RoleType) Includes(other RoleType) bool { return r == other } +func (r RoleType) Children() []RoleType { + switch r { + case ROLE_ADMIN: + return []RoleType{ROLE_EDITOR, ROLE_VIEWER} + case ROLE_EDITOR: + return []RoleType{ROLE_VIEWER} + default: + return nil + } +} + func (r *RoleType) UnmarshalJSON(data []byte) error { var str string err := json.Unmarshal(data, &str) diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go new file mode 100644 index 00000000000..d5cbd2da758 --- /dev/null +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -0,0 +1,44 @@ +package accesscontrol + +import ( + "context" + + "github.com/grafana/grafana/pkg/models" +) + +type AccessControl interface { + Evaluator + Store +} + +type Evaluator interface { + // Evaluate evaluates access to the given resource + Evaluate(ctx context.Context, user *models.SignedInUser, permission string, scope ...string) (bool, error) +} + +type Store interface { + // Database access methods + GetRoles(ctx context.Context, orgID int64) ([]*Role, error) + GetRole(ctx context.Context, orgID, roleID int64) (*RoleDTO, error) + GetRoleByUID(ctx context.Context, orgId int64, uid string) (*RoleDTO, error) + CreateRole(ctx context.Context, cmd CreateRoleCommand) (*Role, error) + CreateRoleWithPermissions(ctx context.Context, cmd CreateRoleWithPermissionsCommand) (*RoleDTO, error) + UpdateRole(ctx context.Context, cmd UpdateRoleCommand) (*RoleDTO, error) + DeleteRole(cmd *DeleteRoleCommand) error + GetRolePermissions(ctx context.Context, roleID int64) ([]Permission, error) + CreatePermission(ctx context.Context, cmd CreatePermissionCommand) (*Permission, error) + UpdatePermission(cmd *UpdatePermissionCommand) (*Permission, error) + DeletePermission(ctx context.Context, cmd *DeletePermissionCommand) error + GetTeamRoles(query *GetTeamRolesQuery) ([]*RoleDTO, error) + GetUserRoles(ctx context.Context, query GetUserRolesQuery) ([]*RoleDTO, error) + GetUserPermissions(ctx context.Context, query GetUserPermissionsQuery) ([]*Permission, error) + AddTeamRole(cmd *AddTeamRoleCommand) error + RemoveTeamRole(cmd *RemoveTeamRoleCommand) error + AddUserRole(cmd *AddUserRoleCommand) error + RemoveUserRole(cmd *RemoveUserRoleCommand) error + AddBuiltinRole(ctx context.Context, orgID, roleID int64, roleName string) error +} + +type Seeder interface { + Seed(ctx context.Context, orgID int64) error +} diff --git a/pkg/services/accesscontrol/database/common_test.go b/pkg/services/accesscontrol/database/common_test.go new file mode 100644 index 00000000000..6b187cf9cb8 --- /dev/null +++ b/pkg/services/accesscontrol/database/common_test.go @@ -0,0 +1,57 @@ +package database + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" +) + +// accessControlStoreTestImpl is a test store implementation which additionally executes a database migrations +type accessControlStoreTestImpl struct { + AccessControlStore +} + +func (ac *accessControlStoreTestImpl) AddMigration(mg *migrator.Migrator) { + AddAccessControlMigrations(mg) +} + +func setupTestEnv(t testing.TB) *accessControlStoreTestImpl { + t.Helper() + + cfg := setting.NewCfg() + store := overrideDatabaseInRegistry(cfg) + sqlStore := sqlstore.InitTestDB(t) + store.SQLStore = sqlStore + + err := store.Init() + require.NoError(t, err) + return &store +} + +func overrideDatabaseInRegistry(cfg *setting.Cfg) accessControlStoreTestImpl { + store := accessControlStoreTestImpl{ + AccessControlStore: AccessControlStore{ + SQLStore: nil, + }, + } + + overrideServiceFunc := func(descriptor registry.Descriptor) (*registry.Descriptor, bool) { + if _, ok := descriptor.Instance.(*AccessControlStore); ok { + return ®istry.Descriptor{ + Name: "Database", + Instance: &store, + InitPriority: descriptor.InitPriority, + }, true + } + return nil, false + } + + registry.RegisterOverride(overrideServiceFunc) + + return store +} diff --git a/pkg/services/accesscontrol/database/database.go b/pkg/services/accesscontrol/database/database.go new file mode 100644 index 00000000000..c5bf8a04ad8 --- /dev/null +++ b/pkg/services/accesscontrol/database/database.go @@ -0,0 +1,696 @@ +package database + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/util" +) + +// TimeNow makes it possible to test usage of time +var TimeNow = time.Now + +type AccessControlStore struct { + SQLStore *sqlstore.SQLStore `inject:""` +} + +func init() { + registry.RegisterService(&AccessControlStore{}) +} + +func (ac *AccessControlStore) Init() error { + return nil +} + +func (ac *AccessControlStore) GetRoles(ctx context.Context, orgID int64) ([]*accesscontrol.Role, error) { + var result []*accesscontrol.Role + err := ac.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + roles := make([]*accesscontrol.Role, 0) + q := "SELECT id, uid, org_id, name, description, updated FROM role WHERE org_id = ?" + if err := sess.SQL(q, orgID).Find(&roles); err != nil { + return err + } + + result = roles + return nil + }) + return result, err +} + +func (ac *AccessControlStore) GetRole(ctx context.Context, orgID, roleID int64) (*accesscontrol.RoleDTO, error) { + var result *accesscontrol.RoleDTO + + err := ac.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + role, err := getRoleById(sess, roleID, orgID) + if err != nil { + return err + } + + permissions, err := getRolePermissions(sess, roleID) + if err != nil { + return err + } + + role.Permissions = permissions + result = role + return nil + }) + + return result, err +} + +func (ac *AccessControlStore) GetRoleByUID(ctx context.Context, orgId int64, uid string) (*accesscontrol.RoleDTO, error) { + var result *accesscontrol.RoleDTO + + err := ac.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + role, err := getRoleByUID(sess, uid, orgId) + if err != nil { + return err + } + + permissions, err := getRolePermissions(sess, role.ID) + if err != nil { + return err + } + + role.Permissions = permissions + result = role + return nil + }) + + return result, err +} + +func (ac *AccessControlStore) CreateRole(ctx context.Context, cmd accesscontrol.CreateRoleCommand) (*accesscontrol.Role, error) { + var result *accesscontrol.Role + + err := ac.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + role, err := ac.createRole(sess, cmd) + if err != nil { + return err + } + + result = role + return nil + }) + + return result, err +} + +func (ac *AccessControlStore) createRole(sess *sqlstore.DBSession, cmd accesscontrol.CreateRoleCommand) (*accesscontrol.Role, error) { + if cmd.UID == "" { + uid, err := generateNewRoleUID(sess, cmd.OrgID) + if err != nil { + return nil, fmt.Errorf("failed to generate UID for role %q: %w", cmd.Name, err) + } + cmd.UID = uid + } + + role := &accesscontrol.Role{ + OrgID: cmd.OrgID, + UID: cmd.UID, + Name: cmd.Name, + Description: cmd.Description, + Created: TimeNow(), + Updated: TimeNow(), + } + + if _, err := sess.Insert(role); err != nil { + if ac.SQLStore.Dialect.IsUniqueConstraintViolation(err) && strings.Contains(err.Error(), "name") { + return nil, fmt.Errorf("role with the name '%s' already exists: %w", cmd.Name, err) + } + return nil, err + } + + return role, nil +} + +func (ac *AccessControlStore) CreateRoleWithPermissions(ctx context.Context, cmd accesscontrol.CreateRoleWithPermissionsCommand) (*accesscontrol.RoleDTO, error) { + var result *accesscontrol.RoleDTO + + err := ac.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + createRoleCmd := accesscontrol.CreateRoleCommand{ + OrgID: cmd.OrgID, + UID: cmd.UID, + Name: cmd.Name, + Description: cmd.Description, + } + + role, err := ac.createRole(sess, createRoleCmd) + if err != nil { + return err + } + + result = &accesscontrol.RoleDTO{ + ID: role.ID, + UID: role.UID, + OrgID: role.OrgID, + Name: role.Name, + Description: role.Description, + Created: role.Created, + Updated: role.Updated, + } + + // Add permissions + for _, p := range cmd.Permissions { + createPermissionCmd := accesscontrol.CreatePermissionCommand{ + RoleID: role.ID, + Permission: p.Permission, + Scope: p.Scope, + } + + permission, err := createPermission(sess, createPermissionCmd) + if err != nil { + return err + } + result.Permissions = append(result.Permissions, *permission) + } + + return nil + }) + + return result, err +} + +// UpdateRole updates role with permissions +func (ac *AccessControlStore) UpdateRole(ctx context.Context, cmd accesscontrol.UpdateRoleCommand) (*accesscontrol.RoleDTO, error) { + var result *accesscontrol.RoleDTO + err := ac.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + // TODO: work with both ID and UID + existingRole, err := getRoleByUID(sess, cmd.UID, cmd.OrgID) + if err != nil { + return err + } + + version := existingRole.Version + 1 + if cmd.Version != 0 { + if existingRole.Version >= cmd.Version { + return fmt.Errorf( + "could not update '%s' (UID %s) from version %d to %d: %w", + cmd.Name, + existingRole.UID, + existingRole.Version, + cmd.Version, + accesscontrol.ErrVersionLE, + ) + } + version = cmd.Version + } + + role := &accesscontrol.Role{ + ID: existingRole.ID, + UID: existingRole.UID, + Version: version, + OrgID: existingRole.OrgID, + Name: cmd.Name, + Description: cmd.Description, + Updated: TimeNow(), + } + + affectedRows, err := sess.ID(existingRole.ID).Update(role) + if err != nil { + return err + } + + if affectedRows == 0 { + return accesscontrol.ErrRoleNotFound + } + + result = &accesscontrol.RoleDTO{ + ID: role.ID, + Version: version, + UID: role.UID, + OrgID: role.OrgID, + Name: role.Name, + Description: role.Description, + Created: role.Created, + Updated: role.Updated, + } + + // Delete role's permissions + _, err = sess.Exec("DELETE FROM permission WHERE role_id = ?", existingRole.ID) + if err != nil { + return err + } + + // Add permissions + for _, p := range cmd.Permissions { + createPermissionCmd := accesscontrol.CreatePermissionCommand{ + RoleID: role.ID, + Permission: p.Permission, + Scope: p.Scope, + } + + permission, err := createPermission(sess, createPermissionCmd) + if err != nil { + return err + } + result.Permissions = append(result.Permissions, *permission) + } + + return nil + }) + + return result, err +} + +func (ac *AccessControlStore) DeleteRole(cmd *accesscontrol.DeleteRoleCommand) error { + return ac.SQLStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + roleId := cmd.ID + if roleId == 0 { + role, err := getRoleByUID(sess, cmd.UID, cmd.OrgID) + if err != nil { + return err + } + roleId = role.ID + } + + // Delete role's permissions + _, err := sess.Exec("DELETE FROM permission WHERE role_id = ?", roleId) + if err != nil { + return err + } + + _, err = sess.Exec("DELETE FROM role WHERE id = ? AND org_id = ?", roleId, cmd.OrgID) + if err != nil { + return err + } + + return nil + }) +} + +func (ac *AccessControlStore) GetRolePermissions(ctx context.Context, roleID int64) ([]accesscontrol.Permission, error) { + var result []accesscontrol.Permission + err := ac.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + permissions, err := getRolePermissions(sess, roleID) + if err != nil { + return err + } + + result = permissions + return nil + }) + return result, err +} + +func (ac *AccessControlStore) CreatePermission(ctx context.Context, cmd accesscontrol.CreatePermissionCommand) (*accesscontrol.Permission, error) { + var result *accesscontrol.Permission + err := ac.SQLStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + permission, err := createPermission(sess, cmd) + if err != nil { + return err + } + + result = permission + return nil + }) + + return result, err +} + +func (ac *AccessControlStore) UpdatePermission(cmd *accesscontrol.UpdatePermissionCommand) (*accesscontrol.Permission, error) { + var result *accesscontrol.Permission + err := ac.SQLStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + permission := &accesscontrol.Permission{ + Permission: cmd.Permission, + Scope: cmd.Scope, + Updated: TimeNow(), + } + + affectedRows, err := sess.ID(cmd.ID).Update(permission) + if err != nil { + return err + } + + if affectedRows == 0 { + return accesscontrol.ErrPermissionNotFound + } + + result = permission + return nil + }) + + return result, err +} + +func (ac *AccessControlStore) DeletePermission(ctx context.Context, cmd *accesscontrol.DeletePermissionCommand) error { + return ac.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + _, err := sess.Exec("DELETE FROM permission WHERE id = ?", cmd.ID) + if err != nil { + return err + } + + return nil + }) +} + +func (ac *AccessControlStore) GetTeamRoles(query *accesscontrol.GetTeamRolesQuery) ([]*accesscontrol.RoleDTO, error) { + var result []*accesscontrol.RoleDTO + err := ac.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + q := `SELECT + role.id, + role.name AS name, + role.description AS description, + role.updated FROM role AS role + INNER JOIN team_role ON role.id = team_role.role_id AND team_role.team_id = ? + WHERE role.org_id = ? ` + + if err := sess.SQL(q, query.TeamID, query.OrgID).Find(&result); err != nil { + return err + } + + return nil + }) + + return result, err +} + +func (ac *AccessControlStore) GetUserRoles(ctx context.Context, query accesscontrol.GetUserRolesQuery) ([]*accesscontrol.RoleDTO, error) { + var result []*accesscontrol.RoleDTO + err := ac.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + // TODO: optimize this + filter, params := ac.userRolesFilter(query.OrgID, query.UserID, query.Roles) + + q := `SELECT + role.id, + role.org_id, + role.name, + role.description, + role.created, + role.updated + FROM role + ` + filter + + err := sess.SQL(q, params...).Find(&result) + return err + }) + + return result, err +} + +func (ac *AccessControlStore) GetUserPermissions(ctx context.Context, query accesscontrol.GetUserPermissionsQuery) ([]*accesscontrol.Permission, error) { + var result []*accesscontrol.Permission + err := ac.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + filter, params := ac.userRolesFilter(query.OrgID, query.UserID, query.Roles) + + // TODO: optimize this + q := `SELECT + permission.id, + permission.role_id, + permission.permission, + permission.scope, + permission.updated, + permission.created + FROM permission + INNER JOIN role ON role.id = permission.role_id + ` + filter + + if err := sess.SQL(q, params...).Find(&result); err != nil { + return err + } + + return nil + }) + + return result, err +} + +func (*AccessControlStore) userRolesFilter(orgID, userID int64, roles []string) (string, []interface{}) { + q := `WHERE role.id IN ( + SELECT up.role_id FROM user_role AS up WHERE up.user_id = ? + UNION + SELECT tp.role_id FROM team_role as tp + INNER JOIN team_member as tm ON tm.team_id = tp.team_id + WHERE tm.user_id = ?` + params := []interface{}{userID, userID} + + if len(roles) != 0 { + q += ` + UNION + SELECT br.role_id FROM builtin_role AS br + WHERE role IN (? ` + strings.Repeat(", ?", len(roles)-1) + `)` + for _, role := range roles { + params = append(params, role) + } + } + + q += `) and role.org_id = ?` + params = append(params, orgID) + + return q, params +} + +func (ac *AccessControlStore) AddTeamRole(cmd *accesscontrol.AddTeamRoleCommand) error { + return ac.SQLStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + if res, err := sess.Query("SELECT 1 from team_role WHERE org_id=? and team_id=? and role_id=?", cmd.OrgID, cmd.TeamID, cmd.RoleID); err != nil { + return err + } else if len(res) == 1 { + return accesscontrol.ErrTeamRoleAlreadyAdded + } + + if _, err := teamExists(cmd.OrgID, cmd.TeamID, sess); err != nil { + return err + } + + if _, err := roleExists(cmd.OrgID, cmd.RoleID, sess); err != nil { + return err + } + + teamRole := &accesscontrol.TeamRole{ + OrgID: cmd.OrgID, + TeamID: cmd.TeamID, + RoleID: cmd.RoleID, + Created: TimeNow(), + } + + _, err := sess.Insert(teamRole) + return err + }) +} + +func (ac *AccessControlStore) RemoveTeamRole(cmd *accesscontrol.RemoveTeamRoleCommand) error { + return ac.SQLStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + if _, err := teamExists(cmd.OrgID, cmd.TeamID, sess); err != nil { + return err + } + + if _, err := roleExists(cmd.OrgID, cmd.RoleID, sess); err != nil { + return err + } + + q := "DELETE FROM team_role WHERE org_id=? and team_id=? and role_id=?" + res, err := sess.Exec(q, cmd.OrgID, cmd.TeamID, cmd.RoleID) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if rows == 0 { + return accesscontrol.ErrTeamRoleNotFound + } + + return err + }) +} + +func (ac *AccessControlStore) AddUserRole(cmd *accesscontrol.AddUserRoleCommand) error { + return ac.SQLStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + if res, err := sess.Query("SELECT 1 from user_role WHERE org_id=? and user_id=? and role_id=?", cmd.OrgID, cmd.UserID, cmd.RoleID); err != nil { + return err + } else if len(res) == 1 { + return accesscontrol.ErrUserRoleAlreadyAdded + } + + if _, err := roleExists(cmd.OrgID, cmd.RoleID, sess); err != nil { + return err + } + + userRole := &accesscontrol.UserRole{ + OrgID: cmd.OrgID, + UserID: cmd.UserID, + RoleID: cmd.RoleID, + Created: TimeNow(), + } + + _, err := sess.Insert(userRole) + return err + }) +} + +func (ac *AccessControlStore) RemoveUserRole(cmd *accesscontrol.RemoveUserRoleCommand) error { + return ac.SQLStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + if _, err := roleExists(cmd.OrgID, cmd.RoleID, sess); err != nil { + return err + } + + q := "DELETE FROM user_role WHERE org_id=? and user_id=? and role_id=?" + res, err := sess.Exec(q, cmd.OrgID, cmd.UserID, cmd.RoleID) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if rows == 0 { + return accesscontrol.ErrUserRoleNotFound + } + + return err + }) +} + +func (ac *AccessControlStore) AddBuiltinRole(ctx context.Context, orgID, roleID int64, roleName string) error { + if !models.RoleType(roleName).IsValid() && roleName != "Grafana Admin" { + return fmt.Errorf("role '%s' is not a valid role", roleName) + } + + return ac.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + if res, err := sess.Query("SELECT 1 from builtin_role WHERE role_id=? and role=?", roleID, roleName); err != nil { + return err + } else if len(res) == 1 { + return accesscontrol.ErrUserRoleAlreadyAdded + } + + if _, err := roleExists(orgID, roleID, sess); err != nil { + return err + } + + role := accesscontrol.BuiltinRole{ + RoleID: roleID, + Role: roleName, + Updated: TimeNow(), + Created: TimeNow(), + } + + _, err := sess.Table("builtin_role").Insert(role) + return err + }) +} + +func getRoleById(sess *sqlstore.DBSession, roleId int64, orgId int64) (*accesscontrol.RoleDTO, error) { + role := accesscontrol.Role{OrgID: orgId, ID: roleId} + has, err := sess.Get(&role) + if !has { + return nil, accesscontrol.ErrRoleNotFound + } + if err != nil { + return nil, err + } + + roleDTO := accesscontrol.RoleDTO{ + ID: roleId, + OrgID: role.OrgID, + Name: role.Name, + Description: role.Description, + Permissions: nil, + Created: role.Created, + Updated: role.Updated, + } + + return &roleDTO, nil +} + +func getRoleByUID(sess *sqlstore.DBSession, uid string, orgId int64) (*accesscontrol.RoleDTO, error) { + role := accesscontrol.Role{OrgID: orgId, UID: uid} + has, err := sess.Get(&role) + if !has { + return nil, accesscontrol.ErrRoleNotFound + } + if err != nil { + return nil, err + } + + roleDTO := accesscontrol.RoleDTO{ + ID: role.ID, + UID: role.UID, + Version: role.Version, + OrgID: role.OrgID, + Name: role.Name, + Description: role.Description, + Permissions: nil, + Created: role.Created, + Updated: role.Updated, + } + + return &roleDTO, nil +} + +func getRolePermissions(sess *sqlstore.DBSession, roleId int64) ([]accesscontrol.Permission, error) { + permissions := make([]accesscontrol.Permission, 0) + q := "SELECT id, role_id, permission, scope, updated, created FROM permission WHERE role_id = ?" + if err := sess.SQL(q, roleId).Find(&permissions); err != nil { + return nil, err + } + + return permissions, nil +} + +func createPermission(sess *sqlstore.DBSession, cmd accesscontrol.CreatePermissionCommand) (*accesscontrol.Permission, error) { + permission := &accesscontrol.Permission{ + RoleID: cmd.RoleID, + Permission: cmd.Permission, + Scope: cmd.Scope, + Created: TimeNow(), + Updated: TimeNow(), + } + + if _, err := sess.Insert(permission); err != nil { + return nil, err + } + + return permission, nil +} + +func teamExists(orgId int64, teamId int64, sess *sqlstore.DBSession) (bool, error) { + if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", orgId, teamId); err != nil { + return false, err + } else if len(res) != 1 { + return false, accesscontrol.ErrTeamNotFound + } + + return true, nil +} + +func roleExists(orgId int64, roleId int64, sess *sqlstore.DBSession) (bool, error) { + if res, err := sess.Query("SELECT 1 from role WHERE org_id=? and id=?", orgId, roleId); err != nil { + return false, err + } else if len(res) != 1 { + return false, accesscontrol.ErrRoleNotFound + } + + return true, nil +} + +func generateNewRoleUID(sess *sqlstore.DBSession, orgID int64) (string, error) { + for i := 0; i < 3; i++ { + uid := util.GenerateShortUID() + + exists, err := sess.Where("org_id=? AND uid=?", orgID, uid).Get(&accesscontrol.Role{}) + if err != nil { + return "", err + } + + if !exists { + return uid, nil + } + } + + return "", accesscontrol.ErrRoleFailedGenerateUniqueUID +} + +func MockTimeNow() { + var timeSeed int64 + TimeNow = func() time.Time { + fakeNow := time.Unix(timeSeed, 0).UTC() + timeSeed++ + return fakeNow + } +} + +func ResetTimeNow() { + TimeNow = time.Now +} diff --git a/pkg/services/accesscontrol/database/database_bench_test.go b/pkg/services/accesscontrol/database/database_bench_test.go new file mode 100644 index 00000000000..00ed0d9ffdd --- /dev/null +++ b/pkg/services/accesscontrol/database/database_bench_test.go @@ -0,0 +1,65 @@ +package database + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/accesscontrol" + actesting "github.com/grafana/grafana/pkg/services/accesscontrol/testing" + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +func setup(b *testing.B, rolesPerUser, users int) *accessControlStoreTestImpl { + ac := setupTestEnv(b) + b.Cleanup(registry.ClearOverrides) + actesting.GenerateRoles(b, ac.SQLStore, ac, rolesPerUser, users) + return ac +} + +func getRoles(b *testing.B, ac accesscontrol.Store, rolesPerUser, users int) { + userQuery := models.GetUserByLoginQuery{ + LoginOrEmail: "user1@test.com", + } + err := sqlstore.GetUserByLogin(&userQuery) + require.NoError(b, err) + userId := userQuery.Result.Id + + userPermissionsQuery := accesscontrol.GetUserPermissionsQuery{OrgID: 1, UserID: userId} + res, err := ac.GetUserPermissions(context.Background(), userPermissionsQuery) + require.NoError(b, err) + expectedPermissions := actesting.PermissionsPerRole * rolesPerUser + assert.Greater(b, len(res), expectedPermissions) +} + +func benchmarkRoles(b *testing.B, rolesPerUser, users int) { + ac := setup(b, rolesPerUser, users) + // We don't wanna measure DB initialization + b.ResetTimer() + + for i := 0; i < b.N; i++ { + getRoles(b, ac, rolesPerUser, users) + } +} + +func BenchmarkRolesUsers10_10(b *testing.B) { benchmarkRoles(b, 10, 10) } + +func BenchmarkRolesUsers10_100(b *testing.B) { benchmarkRoles(b, 10, 100) } +func BenchmarkRolesUsers10_500(b *testing.B) { benchmarkRoles(b, 10, 500) } +func BenchmarkRolesUsers10_1000(b *testing.B) { benchmarkRoles(b, 10, 1000) } +func BenchmarkRolesUsers10_5000(b *testing.B) { benchmarkRoles(b, 10, 5000) } +func BenchmarkRolesUsers10_10000(b *testing.B) { + if testing.Short() { + b.Skip("Skipping benchmark in short mode") + } + benchmarkRoles(b, 10, 10000) +} + +func BenchmarkRolesPerUser10_10(b *testing.B) { benchmarkRoles(b, 10, 10) } +func BenchmarkRolesPerUser100_10(b *testing.B) { benchmarkRoles(b, 100, 10) } +func BenchmarkRolesPerUser500_10(b *testing.B) { benchmarkRoles(b, 500, 10) } +func BenchmarkRolesPerUser1000_10(b *testing.B) { benchmarkRoles(b, 1000, 10) } diff --git a/pkg/services/accesscontrol/database/database_mig.go b/pkg/services/accesscontrol/database/database_mig.go new file mode 100644 index 00000000000..8401b2811a8 --- /dev/null +++ b/pkg/services/accesscontrol/database/database_mig.go @@ -0,0 +1,118 @@ +package database + +import "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + +func AddAccessControlMigrations(mg *migrator.Migrator) { + permissionV1 := migrator.Table{ + Name: "permission", + Columns: []*migrator.Column{ + {Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "role_id", Type: migrator.DB_BigInt}, + {Name: "permission", Type: migrator.DB_Varchar, Length: 190, Nullable: false}, + {Name: "scope", Type: migrator.DB_Varchar, Length: 190, Nullable: false}, + {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, + {Name: "updated", Type: migrator.DB_DateTime, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"role_id"}}, + }, + } + + mg.AddMigration("create permission table", migrator.NewAddTableMigration(permissionV1)) + + //------- indexes ------------------ + mg.AddMigration("add unique index permission.role_id", migrator.NewAddIndexMigration(permissionV1, permissionV1.Indices[0])) + + roleV1 := migrator.Table{ + Name: "role", + Columns: []*migrator.Column{ + {Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "name", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, + {Name: "description", Type: migrator.DB_Text, Nullable: true}, + {Name: "version", Type: migrator.DB_BigInt, Nullable: false}, + {Name: "org_id", Type: migrator.DB_BigInt}, + {Name: "uid", Type: migrator.DB_NVarchar, Length: 40, Nullable: false}, + {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, + {Name: "updated", Type: migrator.DB_DateTime, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"org_id"}}, + {Cols: []string{"org_id", "name"}, Type: migrator.UniqueIndex}, + {Cols: []string{"org_id", "uid"}, Type: migrator.UniqueIndex}, + }, + } + + mg.AddMigration("create role table", migrator.NewAddTableMigration(roleV1)) + + //------- indexes ------------------ + mg.AddMigration("add index role.org_id", migrator.NewAddIndexMigration(roleV1, roleV1.Indices[0])) + mg.AddMigration("add unique index role_org_id_name", migrator.NewAddIndexMigration(roleV1, roleV1.Indices[1])) + mg.AddMigration("add index role_org_id_uid", migrator.NewAddIndexMigration(roleV1, roleV1.Indices[2])) + + teamRoleV1 := migrator.Table{ + Name: "team_role", + Columns: []*migrator.Column{ + {Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "org_id", Type: migrator.DB_BigInt}, + {Name: "team_id", Type: migrator.DB_BigInt}, + {Name: "role_id", Type: migrator.DB_BigInt}, + {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"org_id"}}, + {Cols: []string{"org_id", "team_id", "role_id"}, Type: migrator.UniqueIndex}, + {Cols: []string{"team_id"}}, + }, + } + + mg.AddMigration("create team role table", migrator.NewAddTableMigration(teamRoleV1)) + + //------- indexes ------------------ + mg.AddMigration("add index team_role.org_id", migrator.NewAddIndexMigration(teamRoleV1, teamRoleV1.Indices[0])) + mg.AddMigration("add unique index team_role_org_id_team_id_role_id", migrator.NewAddIndexMigration(teamRoleV1, teamRoleV1.Indices[1])) + mg.AddMigration("add index team_role.team_id", migrator.NewAddIndexMigration(teamRoleV1, teamRoleV1.Indices[2])) + + userRoleV1 := migrator.Table{ + Name: "user_role", + Columns: []*migrator.Column{ + {Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "org_id", Type: migrator.DB_BigInt}, + {Name: "user_id", Type: migrator.DB_BigInt}, + {Name: "role_id", Type: migrator.DB_BigInt}, + {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"org_id"}}, + {Cols: []string{"org_id", "user_id", "role_id"}, Type: migrator.UniqueIndex}, + {Cols: []string{"user_id"}}, + }, + } + + mg.AddMigration("create user role table", migrator.NewAddTableMigration(userRoleV1)) + + //------- indexes ------------------ + mg.AddMigration("add index user_role.org_id", migrator.NewAddIndexMigration(userRoleV1, userRoleV1.Indices[0])) + mg.AddMigration("add unique index user_role_org_id_user_id_role_id", migrator.NewAddIndexMigration(userRoleV1, userRoleV1.Indices[1])) + mg.AddMigration("add index user_role.user_id", migrator.NewAddIndexMigration(userRoleV1, userRoleV1.Indices[2])) + + builtinRoleV1 := migrator.Table{ + Name: "builtin_role", + Columns: []*migrator.Column{ + {Name: "id", Type: migrator.DB_BigInt, IsPrimaryKey: true, IsAutoIncrement: true}, + {Name: "role", Type: migrator.DB_NVarchar, Length: 190, Nullable: false}, + {Name: "role_id", Type: migrator.DB_BigInt}, + {Name: "created", Type: migrator.DB_DateTime, Nullable: false}, + {Name: "updated", Type: migrator.DB_DateTime, Nullable: false}, + }, + Indices: []*migrator.Index{ + {Cols: []string{"role_id"}}, + {Cols: []string{"role"}}, + }, + } + + mg.AddMigration("create builtin role table", migrator.NewAddTableMigration(builtinRoleV1)) + + //------- indexes ------------------ + mg.AddMigration("add index builtin_role.role_id", migrator.NewAddIndexMigration(builtinRoleV1, builtinRoleV1.Indices[0])) + mg.AddMigration("add index builtin_role.name", migrator.NewAddIndexMigration(builtinRoleV1, builtinRoleV1.Indices[1])) +} diff --git a/pkg/services/accesscontrol/database/database_test.go b/pkg/services/accesscontrol/database/database_test.go new file mode 100644 index 00000000000..992e7b2f32a --- /dev/null +++ b/pkg/services/accesscontrol/database/database_test.go @@ -0,0 +1,387 @@ +// +build integration + +package database + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/accesscontrol" + actesting "github.com/grafana/grafana/pkg/services/accesscontrol/testing" + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +func TestCreatingRole(t *testing.T) { + MockTimeNow() + t.Cleanup(ResetTimeNow) + + testCases := []struct { + desc string + role actesting.RoleTestCase + permissions []actesting.PermissionTestCase + + expectedError error + expectedUpdated time.Time + }{ + { + desc: "should successfully create simple role", + role: actesting.RoleTestCase{ + Name: "a name", + Permissions: nil, + }, + expectedUpdated: time.Unix(1, 0).UTC(), + }, + { + desc: "should successfully create role with UID", + role: actesting.RoleTestCase{ + Name: "a name", + UID: "testUID", + Permissions: nil, + }, + expectedUpdated: time.Unix(3, 0).UTC(), + }, + { + desc: "should successfully create role with permissions", + role: actesting.RoleTestCase{ + Name: "a name", + Permissions: []actesting.PermissionTestCase{ + {Scope: "users", Permission: "admin.users:create"}, + {Scope: "reports", Permission: "reports:read"}, + }, + }, + expectedUpdated: time.Unix(5, 0).UTC(), + }, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + store := setupTestEnv(t) + t.Cleanup(registry.ClearOverrides) + + createRoleRes := actesting.CreateRole(t, store, tc.role) + + res, err := store.GetRoleByUID(context.Background(), 1, createRoleRes.UID) + role := res + require.NoError(t, err) + assert.Equal(t, tc.expectedUpdated, role.Updated) + + if tc.role.UID != "" { + assert.Equal(t, tc.role.UID, role.UID) + } + + if tc.role.Permissions == nil { + assert.Empty(t, role.Permissions) + } else { + assert.Len(t, tc.role.Permissions, len(role.Permissions)) + for i, p := range role.Permissions { + assert.Equal(t, tc.role.Permissions[i].Permission, p.Permission) + assert.Equal(t, tc.role.Permissions[i].Scope, p.Scope) + } + } + }) + } +} + +func TestUpdatingRole(t *testing.T) { + MockTimeNow() + t.Cleanup(ResetTimeNow) + + testCases := []struct { + desc string + role actesting.RoleTestCase + newRole actesting.RoleTestCase + + expectedError error + }{ + { + desc: "should successfully update role name", + role: actesting.RoleTestCase{ + Name: "a name", + Permissions: []actesting.PermissionTestCase{ + {Scope: "reports", Permission: "reports:read"}, + }, + }, + newRole: actesting.RoleTestCase{ + Name: "a different name", + Permissions: []actesting.PermissionTestCase{ + {Scope: "reports", Permission: "reports:create"}, + {Scope: "reports", Permission: "reports:read"}, + }, + }, + }, + { + desc: "should successfully create role with permissions", + role: actesting.RoleTestCase{ + Name: "a name", + Permissions: []actesting.PermissionTestCase{ + {Scope: "users", Permission: "admin.users:create"}, + {Scope: "reports", Permission: "reports:read"}, + }, + }, + newRole: actesting.RoleTestCase{ + Name: "a different name", + Permissions: []actesting.PermissionTestCase{ + {Scope: "users", Permission: "admin.users:read"}, + {Scope: "reports", Permission: "reports:create"}, + }, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + store := setupTestEnv(t) + t.Cleanup(registry.ClearOverrides) + + role := actesting.CreateRole(t, store, tc.role) + updated := role.Updated + + updateRoleCmd := accesscontrol.UpdateRoleCommand{ + UID: role.UID, + Name: tc.newRole.Name, + } + for _, perm := range tc.newRole.Permissions { + updateRoleCmd.Permissions = append(updateRoleCmd.Permissions, accesscontrol.Permission{ + Permission: perm.Permission, + Scope: perm.Scope, + }) + } + + _, err := store.UpdateRole(context.Background(), updateRoleCmd) + require.NoError(t, err) + + updatedRole, err := store.GetRoleByUID(context.Background(), 1, role.UID) + require.NoError(t, err) + assert.Equal(t, tc.newRole.Name, updatedRole.Name) + assert.True(t, updatedRole.Updated.After(updated)) + assert.Equal(t, len(tc.newRole.Permissions), len(updatedRole.Permissions)) + + // Check permissions + require.NoError(t, err) + for i, updatedPermission := range updatedRole.Permissions { + assert.Equal(t, tc.newRole.Permissions[i].Permission, updatedPermission.Permission) + assert.Equal(t, tc.newRole.Permissions[i].Scope, updatedPermission.Scope) + } + }) + } +} + +type userRoleTestCase struct { + desc string + userName string + teamName string + userRoles []actesting.RoleTestCase + teamRoles []actesting.RoleTestCase +} + +func TestUserRole(t *testing.T) { + MockTimeNow() + t.Cleanup(ResetTimeNow) + + testCases := []userRoleTestCase{ + { + desc: "should successfully get user roles", + userName: "testuser", + teamName: "team1", + userRoles: []actesting.RoleTestCase{ + { + Name: "CreateUser", Permissions: []actesting.PermissionTestCase{}, + }, + }, + teamRoles: nil, + }, + { + desc: "should successfully get user and team roles", + userName: "testuser", + teamName: "team1", + userRoles: []actesting.RoleTestCase{ + { + Name: "CreateUser", Permissions: []actesting.PermissionTestCase{}, + }, + }, + teamRoles: []actesting.RoleTestCase{ + { + Name: "CreateDataSource", Permissions: []actesting.PermissionTestCase{}, + }, + { + Name: "EditDataSource", Permissions: []actesting.PermissionTestCase{}, + }, + }, + }, + { + desc: "should successfully get user and team roles if user has no roles", + userName: "testuser", + teamName: "team1", + userRoles: nil, + teamRoles: []actesting.RoleTestCase{ + { + Name: "CreateDataSource", Permissions: []actesting.PermissionTestCase{}, + }, + { + Name: "EditDataSource", Permissions: []actesting.PermissionTestCase{}, + }, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + store := setupTestEnv(t) + t.Cleanup(registry.ClearOverrides) + + actesting.CreateUserWithRole(t, store.SQLStore, store, tc.userName, tc.userRoles) + actesting.CreateTeamWithRole(t, store.SQLStore, store, tc.teamName, tc.teamRoles) + + // Create more teams + for i := 0; i < 10; i++ { + teamName := fmt.Sprintf("faketeam%v", i) + roles := []actesting.RoleTestCase{ + { + Name: fmt.Sprintf("fakerole%v", i), + Permissions: []actesting.PermissionTestCase{ + {Scope: "datasources", Permission: "datasources:create"}, + }, + }, + } + actesting.CreateTeamWithRole(t, store.SQLStore, store, teamName, roles) + } + + userQuery := models.GetUserByLoginQuery{ + LoginOrEmail: tc.userName, + } + err := sqlstore.GetUserByLogin(&userQuery) + require.NoError(t, err) + userId := userQuery.Result.Id + + teamQuery := models.SearchTeamsQuery{ + OrgId: 1, + Name: tc.teamName, + } + err = sqlstore.SearchTeams(&teamQuery) + require.NoError(t, err) + require.Len(t, teamQuery.Result.Teams, 1) + teamId := teamQuery.Result.Teams[0].Id + + err = store.SQLStore.AddTeamMember(userId, 1, teamId, false, 1) + require.NoError(t, err) + + userRolesQuery := accesscontrol.GetUserRolesQuery{ + OrgID: 1, + UserID: userQuery.Result.Id, + } + + res, err := store.GetUserRoles(context.Background(), userRolesQuery) + require.NoError(t, err) + assert.Equal(t, len(tc.userRoles)+len(tc.teamRoles), len(res)) + }) + } +} + +type userTeamRoleTestCase struct { + desc string + userName string + teamName string + userRoles []actesting.RoleTestCase + teamRoles []actesting.RoleTestCase +} + +func TestUserPermissions(t *testing.T) { + MockTimeNow() + t.Cleanup(ResetTimeNow) + + testCases := []userTeamRoleTestCase{ + { + desc: "should successfully get user and team permissions", + userName: "testuser", + teamName: "team1", + userRoles: []actesting.RoleTestCase{ + { + Name: "CreateUser", Permissions: []actesting.PermissionTestCase{ + {Scope: "users", Permission: "admin.users:create"}, + {Scope: "reports", Permission: "reports:read"}, + }, + }, + }, + teamRoles: []actesting.RoleTestCase{ + { + Name: "CreateDataSource", Permissions: []actesting.PermissionTestCase{ + {Scope: "datasources", Permission: "datasources:create"}, + }, + }, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + store := setupTestEnv(t) + t.Cleanup(registry.ClearOverrides) + + actesting.CreateUserWithRole(t, store.SQLStore, store, tc.userName, tc.userRoles) + actesting.CreateTeamWithRole(t, store.SQLStore, store, tc.teamName, tc.teamRoles) + + // Create more teams + for i := 0; i < 10; i++ { + teamName := fmt.Sprintf("faketeam%v", i) + roles := []actesting.RoleTestCase{ + { + Name: fmt.Sprintf("fakerole%v", i), + Permissions: []actesting.PermissionTestCase{ + {Scope: "datasources", Permission: "datasources:create"}, + }, + }, + } + actesting.CreateTeamWithRole(t, store.SQLStore, store, teamName, roles) + } + + userQuery := models.GetUserByLoginQuery{ + LoginOrEmail: tc.userName, + } + err := sqlstore.GetUserByLogin(&userQuery) + require.NoError(t, err) + userId := userQuery.Result.Id + + teamQuery := models.SearchTeamsQuery{ + OrgId: 1, + Name: tc.teamName, + } + err = sqlstore.SearchTeams(&teamQuery) + require.NoError(t, err) + require.Len(t, teamQuery.Result.Teams, 1) + teamId := teamQuery.Result.Teams[0].Id + + err = store.SQLStore.AddTeamMember(userId, 1, teamId, false, 1) + require.NoError(t, err) + + userPermissionsQuery := accesscontrol.GetUserPermissionsQuery{ + OrgID: 1, + UserID: userId, + } + + getUserTeamsQuery := models.GetTeamsByUserQuery{ + OrgId: 1, + UserId: userId, + } + err = sqlstore.GetTeamsByUser(&getUserTeamsQuery) + require.NoError(t, err) + require.Len(t, getUserTeamsQuery.Result, 1) + + expectedPermissions := []actesting.PermissionTestCase{} + for _, p := range tc.userRoles { + expectedPermissions = append(expectedPermissions, p.Permissions...) + } + for _, p := range tc.teamRoles { + expectedPermissions = append(expectedPermissions, p.Permissions...) + } + + res, err := store.GetUserPermissions(context.Background(), userPermissionsQuery) + require.NoError(t, err) + assert.Len(t, res, len(expectedPermissions)) + assert.Contains(t, expectedPermissions, actesting.PermissionTestCase{Scope: "datasources", Permission: "datasources:create"}) + assert.NotContains(t, expectedPermissions, actesting.PermissionTestCase{Scope: "/api/restricted", Permission: "restricted:read"}) + }) + } +} diff --git a/pkg/services/accesscontrol/manager/evaluator.go b/pkg/services/accesscontrol/manager/evaluator.go new file mode 100644 index 00000000000..f731730fbf3 --- /dev/null +++ b/pkg/services/accesscontrol/manager/evaluator.go @@ -0,0 +1,74 @@ +package manager + +import ( + "context" + + "github.com/gobwas/glob" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +const roleGrafanaAdmin = "Grafana Admin" + +func (m *Manager) Evaluate(ctx context.Context, user *models.SignedInUser, permission string, scope ...string) (bool, error) { + roles := []string{string(user.OrgRole)} + for _, role := range user.OrgRole.Children() { + roles = append(roles, string(role)) + } + if user.IsGrafanaAdmin { + roles = append(roles, roleGrafanaAdmin) + } + + res, err := m.GetUserPermissions(ctx, accesscontrol.GetUserPermissionsQuery{ + OrgID: user.OrgId, + UserID: user.UserId, + Roles: roles, + }) + if err != nil { + return false, err + } + + ok, dbScopes := extractPermission(res, permission) + if !ok { + return false, nil + } + + for _, s := range scope { + var match bool + for dbScope := range dbScopes { + rule, err := glob.Compile(dbScope, ':', '/') + if err != nil { + return false, err + } + + match = rule.Match(s) + if match { + break + } + } + + if !match { + return false, nil + } + } + + return true, nil +} + +func extractPermission(permissions []*accesscontrol.Permission, permission string) (bool, map[string]struct{}) { + scopes := map[string]struct{}{} + ok := false + + for _, p := range permissions { + if p == nil { + continue + } + if p.Permission == permission { + ok = true + scopes[p.Scope] = struct{}{} + } + } + + return ok, scopes +} diff --git a/pkg/services/accesscontrol/manager/manager.go b/pkg/services/accesscontrol/manager/manager.go new file mode 100644 index 00000000000..b8bdf224e44 --- /dev/null +++ b/pkg/services/accesscontrol/manager/manager.go @@ -0,0 +1,53 @@ +package manager + +import ( + "context" + + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/accesscontrol/database" + "github.com/grafana/grafana/pkg/services/accesscontrol/seeder" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" +) + +// Manager is the service implementing role based access control. +type Manager struct { + Cfg *setting.Cfg `inject:""` + RouteRegister routing.RouteRegister `inject:""` + Log log.Logger + *database.AccessControlStore +} + +func init() { + registry.RegisterService(&Manager{}) +} + +// Init initializes the Manager. +func (m *Manager) Init() error { + m.Log = log.New("accesscontrol") + + seeder := seeder.NewSeeder(m, m.Log) + + // TODO: Seed all orgs + err := seeder.Seed(context.TODO(), 1) + if err != nil { + return err + } + + return nil +} + +func (m *Manager) IsDisabled() bool { + _, exists := m.Cfg.FeatureToggles["accesscontrol"] + return !exists +} + +func (m *Manager) AddMigration(mg *migrator.Migrator) { + if m.IsDisabled() { + return + } + + database.AddAccessControlMigrations(mg) +} diff --git a/pkg/services/accesscontrol/manager/manager_test.go b/pkg/services/accesscontrol/manager/manager_test.go new file mode 100644 index 00000000000..67b88151870 --- /dev/null +++ b/pkg/services/accesscontrol/manager/manager_test.go @@ -0,0 +1,118 @@ +package manager + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/database" + actesting "github.com/grafana/grafana/pkg/services/accesscontrol/testing" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" +) + +func setupTestEnv(t testing.TB) *Manager { + t.Helper() + + cfg := setting.NewCfg() + cfg.FeatureToggles = map[string]bool{"accesscontrol": true} + + ac := overrideAccessControlInRegistry(t, cfg) + + sqlStore := sqlstore.InitTestDB(t) + ac.AccessControlStore.SQLStore = sqlStore + + err := ac.Init() + require.NoError(t, err) + return &ac +} + +func overrideAccessControlInRegistry(t testing.TB, cfg *setting.Cfg) Manager { + t.Helper() + + ac := Manager{ + Cfg: cfg, + RouteRegister: routing.NewRouteRegister(), + Log: log.New("accesscontrol-test"), + AccessControlStore: &database.AccessControlStore{ + SQLStore: nil, + }, + } + + overrideServiceFunc := func(descriptor registry.Descriptor) (*registry.Descriptor, bool) { + if _, ok := descriptor.Instance.(*Manager); ok { + return ®istry.Descriptor{ + Name: "AccessControl", + Instance: &ac, + InitPriority: descriptor.InitPriority, + }, true + } + return nil, false + } + + registry.RegisterOverride(overrideServiceFunc) + + return ac +} + +type evaluatingPermissionsTestCase struct { + desc string + userName string + roles []actesting.RoleTestCase +} + +func TestEvaluatingPermissions(t *testing.T) { + testCases := []evaluatingPermissionsTestCase{ + { + desc: "should successfully evaluate access to the endpoint", + userName: "testuser", + roles: []actesting.RoleTestCase{ + { + Name: "CreateUser", Permissions: []actesting.PermissionTestCase{ + {Scope: "/api/admin/users", Permission: "post"}, + {Scope: "/api/report", Permission: "get"}, + }, + }, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + ac := setupTestEnv(t) + t.Cleanup(registry.ClearOverrides) + + actesting.CreateUserWithRole(t, ac.SQLStore, ac, tc.userName, tc.roles) + + userQuery := models.GetUserByLoginQuery{ + LoginOrEmail: tc.userName, + } + err := sqlstore.GetUserByLogin(&userQuery) + require.NoError(t, err) + + userRolesQuery := accesscontrol.GetUserRolesQuery{ + OrgID: 1, + UserID: userQuery.Result.Id, + } + + res, err := ac.GetUserRoles(context.Background(), userRolesQuery) + require.NoError(t, err) + assert.Len(t, res, len(tc.roles)) + + userPermissionsQuery := accesscontrol.GetUserPermissionsQuery{ + OrgID: 1, + UserID: userQuery.Result.Id, + } + + permissions, err := ac.GetUserPermissions(context.Background(), userPermissionsQuery) + require.NoError(t, err) + assert.Len(t, permissions, len(tc.roles[0].Permissions)) + }) + } +} diff --git a/pkg/services/accesscontrol/middleware/middleware.go b/pkg/services/accesscontrol/middleware/middleware.go new file mode 100644 index 00000000000..e363db6cefb --- /dev/null +++ b/pkg/services/accesscontrol/middleware/middleware.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "bytes" + "net/http" + "text/template" + + macaron "gopkg.in/macaron.v1" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +func Middleware(ac accesscontrol.AccessControl) func(string, ...string) macaron.Handler { + return func(permission string, scopes ...string) macaron.Handler { + return func(c *models.ReqContext) { + for i, scope := range scopes { + var buf bytes.Buffer + + tmpl, err := template.New("scope").Parse(scope) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Internal server error", err) + return + } + err = tmpl.Execute(&buf, c.AllParams()) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Internal server error", err) + return + } + scopes[i] = buf.String() + } + + hasAccess, err := ac.Evaluate(c.Req.Context(), c.SignedInUser, permission, scopes...) + if err != nil { + c.Logger.Error("Error from access control system", "error", err) + c.JsonApiErr(http.StatusForbidden, "Forbidden", nil) + return + } + if !hasAccess { + c.Logger.Info("Access denied", "error", err, "userID", c.UserId, "permission", permission, "scopes", scopes) + c.JsonApiErr(http.StatusForbidden, "Forbidden", nil) + return + } + } + } +} diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go new file mode 100644 index 00000000000..c96414ee078 --- /dev/null +++ b/pkg/services/accesscontrol/models.go @@ -0,0 +1,188 @@ +package accesscontrol + +import ( + "errors" + "time" +) + +var ( + ErrRoleNotFound = errors.New("role not found") + ErrTeamRoleAlreadyAdded = errors.New("role is already added to this team") + ErrUserRoleAlreadyAdded = errors.New("role is already added to this user") + ErrTeamRoleNotFound = errors.New("team role not found") + ErrUserRoleNotFound = errors.New("user role not found") + ErrTeamNotFound = errors.New("team not found") + ErrPermissionNotFound = errors.New("permission not found") + ErrRoleFailedGenerateUniqueUID = errors.New("failed to generate role definition UID") + ErrVersionLE = errors.New("the provided role version is smaller than or equal to stored role") +) + +// Role is the model for Role in RBAC. +type Role struct { + ID int64 `json:"id" xorm:"pk autoincr 'id'"` + OrgID int64 `json:"orgId" xorm:"org_id"` + Version int64 `json:"version"` + UID string `xorm:"uid" json:"uid"` + Name string `json:"name"` + Description string `json:"description"` + + Updated time.Time `json:"updated"` + Created time.Time `json:"created"` +} + +type RoleDTO struct { + ID int64 `json:"id" xorm:"pk autoincr 'id'"` + OrgID int64 `json:"orgId" xorm:"org_id"` + Version int64 `json:"version"` + UID string `xorm:"uid" json:"uid"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []Permission `json:"permissions,omitempty"` + + Updated time.Time `json:"updated"` + Created time.Time `json:"created"` +} + +// Permission is the model for Permission in RBAC. +type Permission struct { + ID int64 `json:"id" xorm:"pk autoincr 'id'"` + RoleID int64 `json:"-" xorm:"role_id"` + Permission string `json:"permission"` + Scope string `json:"scope"` + + Updated time.Time `json:"updated"` + Created time.Time `json:"created"` +} + +type TeamRole struct { + ID int64 `json:"id" xorm:"pk autoincr 'id'"` + OrgID int64 `json:"orgId" xorm:"org_id"` + RoleID int64 `json:"roleId" xorm:"role_id"` + TeamID int64 `json:"teamId" xorm:"team_id"` + + Created time.Time +} + +type UserRole struct { + ID int64 `json:"id" xorm:"pk autoincr 'id'"` + OrgID int64 `json:"orgId" xorm:"org_id"` + RoleID int64 `json:"roleId" xorm:"role_id"` + UserID int64 `json:"userId" xorm:"user_id"` + + Created time.Time +} + +type BuiltinRole struct { + ID *int64 `json:"id" xorm:"pk autoincr 'id'"` + RoleID int64 `json:"roleId" xorm:"role_id"` + Role string + + Updated time.Time + Created time.Time +} + +type GetTeamRolesQuery struct { + OrgID int64 `json:"-"` + TeamID int64 `json:"teamId"` +} + +type GetUserRolesQuery struct { + OrgID int64 `json:"-"` + UserID int64 `json:"userId"` + Roles []string +} + +type GetUserPermissionsQuery struct { + OrgID int64 `json:"-"` + UserID int64 `json:"userId"` + Roles []string +} + +type CreatePermissionCommand struct { + RoleID int64 `json:"roleId"` + Permission string + Scope string +} + +type UpdatePermissionCommand struct { + ID int64 `json:"id"` + Permission string + Scope string +} + +type DeletePermissionCommand struct { + ID int64 `json:"id"` +} + +type CreateRoleCommand struct { + OrgID int64 `json:"-"` + UID string `json:"uid"` + Version int64 `json:"version"` + Name string `json:"name"` + Description string `json:"description"` +} + +type CreateRoleWithPermissionsCommand struct { + OrgID int64 `json:"orgId"` + UID string `json:"uid"` + Version int64 `json:"version"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []Permission `json:"permissions"` +} + +type UpdateRoleCommand struct { + ID int64 `json:"id"` + OrgID int64 `json:"orgId"` + Version int64 `json:"version"` + UID string `json:"uid"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []Permission `json:"permissions"` +} + +type DeleteRoleCommand struct { + ID int64 `json:"id"` + UID string `json:"uid"` + OrgID int64 `json:"org_id"` +} + +type AddTeamRoleCommand struct { + OrgID int64 `json:"org_id"` + RoleID int64 `json:"role_id"` + TeamID int64 `json:"team_id"` +} + +type RemoveTeamRoleCommand struct { + OrgID int64 `json:"org_id"` + RoleID int64 `json:"role_id"` + TeamID int64 `json:"team_id"` +} + +type AddUserRoleCommand struct { + OrgID int64 `json:"org_id"` + RoleID int64 `json:"role_id"` + UserID int64 `json:"user_id"` +} + +type RemoveUserRoleCommand struct { + OrgID int64 `json:"org_id"` + RoleID int64 `json:"role_id"` + UserID int64 `json:"user_id"` +} + +type EvaluationResult struct { + HasAccess bool + Meta interface{} +} + +func (p RoleDTO) Role() Role { + return Role{ + ID: p.ID, + OrgID: p.OrgID, + Name: p.Name, + Description: p.Description, + Updated: p.Updated, + Created: p.Created, + } +} diff --git a/pkg/services/accesscontrol/seeder/seeder.go b/pkg/services/accesscontrol/seeder/seeder.go new file mode 100644 index 00000000000..aad5bb6c423 --- /dev/null +++ b/pkg/services/accesscontrol/seeder/seeder.go @@ -0,0 +1,219 @@ +package seeder + +import ( + "context" + "errors" + "fmt" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +type seeder struct { + Store accesscontrol.Store + log log.Logger +} + +var builtInRoles = []accesscontrol.RoleDTO{ + { + Name: "grafana:builtin:users:read:self", + Version: 1, + Permissions: []accesscontrol.Permission{ + { + Permission: "users:read", + Scope: "users:self", + }, + { + Permission: "users.tokens:list", + Scope: "users:self", + }, + { + Permission: "users.teams:read", + Scope: "users:self", + }, + }, + }, +} + +// FIXME: Make sure builtin grants can be removed without being recreated +var builtInRoleGrants = map[string][]string{ + "grafana:builtin:users:read:self": { + "Viewer", + }, +} + +func NewSeeder(s accesscontrol.AccessControl, log log.Logger) *seeder { + return &seeder{Store: s, log: log} +} + +func (s *seeder) Seed(ctx context.Context, orgID int64) error { + err := s.seed(ctx, orgID, builtInRoles, builtInRoleGrants) + return err +} + +func (s *seeder) seed(ctx context.Context, orgID int64, roles []accesscontrol.RoleDTO, roleGrants map[string][]string) error { + // FIXME: As this will run on startup, we want to optimize running this + existingRoles, err := s.Store.GetRoles(ctx, orgID) + if err != nil { + return err + } + roleSet := map[string]*accesscontrol.Role{} + for _, role := range existingRoles { + if role == nil { + continue + } + roleSet[role.Name] = role + } + + for _, role := range roles { + role.OrgID = orgID + + current, exists := roleSet[role.Name] + if exists { + if role.Version <= current.Version { + continue + } + } + + roleID, err := s.createOrUpdateRole(ctx, role, current) + if err != nil { + s.log.Error("failed to create/update role", "name", role.Name, "err", err) + continue + } + + if builtinRoles, exists := roleGrants[role.Name]; exists { + for _, builtinRole := range builtinRoles { + err := s.Store.AddBuiltinRole(ctx, orgID, roleID, builtinRole) + if err != nil && !errors.Is(err, accesscontrol.ErrUserRoleAlreadyAdded) { + s.log.Error("failed to assign role to role", + "name", role.Name, + "role", builtinRole, + "err", err, + ) + return err + } + } + } + } + + return nil +} + +func (s *seeder) createOrUpdateRole(ctx context.Context, role accesscontrol.RoleDTO, old *accesscontrol.Role) (int64, error) { + if role.Version == 0 { + return 0, fmt.Errorf("error when seeding '%s': all seeder roles must have a version", role.Name) + } + + if old == nil { + p, err := s.Store.CreateRoleWithPermissions(ctx, accesscontrol.CreateRoleWithPermissionsCommand{ + OrgID: role.OrgID, + Version: role.Version, + Name: role.Name, + Description: role.Description, + Permissions: role.Permissions, + }) + if err != nil { + return 0, err + } + return p.ID, nil + } + + _, err := s.Store.UpdateRole(ctx, accesscontrol.UpdateRoleCommand{ + UID: old.UID, + Name: role.Name, + Description: role.Description, + Version: role.Version, + }) + if err != nil { + if errors.Is(err, accesscontrol.ErrVersionLE) { + return old.ID, nil + } + return 0, err + } + + existingPermissions, err := s.Store.GetRolePermissions(ctx, old.ID) + if err != nil { + return 0, fmt.Errorf("failed to get current permissions for role '%s': %w", role.Name, err) + } + + err = s.idempotentUpdatePermissions(ctx, old.ID, role.Permissions, existingPermissions) + if err != nil { + return 0, fmt.Errorf("failed to update role permissions for role '%s': %w", role.Name, err) + } + return old.ID, nil +} + +func (s *seeder) idempotentUpdatePermissions(ctx context.Context, roleID int64, new []accesscontrol.Permission, old []accesscontrol.Permission) error { + if roleID == 0 { + return fmt.Errorf("refusing to add permissions to role with ID 0 (it should not exist)") + } + + added, removed := diffPermissionList(new, old) + + for _, p := range added { + _, err := s.Store.CreatePermission(ctx, accesscontrol.CreatePermissionCommand{ + RoleID: roleID, + Permission: p.Permission, + Scope: p.Scope, + }) + if err != nil { + return fmt.Errorf("could not create permission %s (%s): %w", p.Permission, p.Scope, err) + } + } + + for _, p := range removed { + err := s.Store.DeletePermission(ctx, &accesscontrol.DeletePermissionCommand{ + ID: p.ID, + }) + if err != nil { + return fmt.Errorf("could not delete permission %s (%s): %w", p.Permission, p.Scope, err) + } + } + + return nil +} + +func diffPermissionList(new, old []accesscontrol.Permission) (added, removed []accesscontrol.Permission) { + newMap, oldMap := permissionMap(new), permissionMap(old) + + added = []accesscontrol.Permission{} + removed = []accesscontrol.Permission{} + + for _, p := range newMap { + if _, exists := oldMap[permissionTuple{ + Permission: p.Permission, + Scope: p.Scope, + }]; exists { + continue + } + added = append(added, p) + } + + for _, p := range oldMap { + if _, exists := newMap[permissionTuple{ + Permission: p.Permission, + Scope: p.Scope, + }]; exists { + continue + } + removed = append(removed, p) + } + + return added, removed +} + +type permissionTuple struct { + Permission string + Scope string +} + +func permissionMap(l []accesscontrol.Permission) map[permissionTuple]accesscontrol.Permission { + m := make(map[permissionTuple]accesscontrol.Permission, len(l)) + for _, p := range l { + m[permissionTuple{ + Permission: p.Permission, + Scope: p.Scope, + }] = p + } + return m +} diff --git a/pkg/services/accesscontrol/seeder/seeder_test.go b/pkg/services/accesscontrol/seeder/seeder_test.go new file mode 100644 index 00000000000..026d0c617ff --- /dev/null +++ b/pkg/services/accesscontrol/seeder/seeder_test.go @@ -0,0 +1,156 @@ +package seeder + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/database" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" +) + +// accessControlStoreTestImpl is a test store implementation which additionally executes a database migrations +type accessControlStoreTestImpl struct { + database.AccessControlStore +} + +func (ac *accessControlStoreTestImpl) AddMigration(mg *migrator.Migrator) { + database.AddAccessControlMigrations(mg) +} + +func setupTestEnv(t testing.TB) *accessControlStoreTestImpl { + t.Helper() + + cfg := setting.NewCfg() + store := overrideDatabaseInRegistry(t, cfg) + sqlStore := sqlstore.InitTestDB(t) + store.SQLStore = sqlStore + + err := store.Init() + require.NoError(t, err) + return &store +} + +func overrideDatabaseInRegistry(t testing.TB, cfg *setting.Cfg) accessControlStoreTestImpl { + t.Helper() + + store := accessControlStoreTestImpl{ + AccessControlStore: database.AccessControlStore{ + SQLStore: nil, + }, + } + + overrideServiceFunc := func(descriptor registry.Descriptor) (*registry.Descriptor, bool) { + if _, ok := descriptor.Instance.(*database.AccessControlStore); ok { + return ®istry.Descriptor{ + Name: "Database", + Instance: &store, + InitPriority: descriptor.InitPriority, + }, true + } + return nil, false + } + + registry.RegisterOverride(overrideServiceFunc) + + return store +} + +func TestSeeder(t *testing.T) { + ac := setupTestEnv(t) + + s := &seeder{ + Store: ac, + log: log.New("accesscontrol-test"), + } + + v1 := accesscontrol.RoleDTO{ + OrgID: 1, + Name: "grafana:tests:fake", + Version: 1, + Permissions: []accesscontrol.Permission{ + { + Permission: "ice_cream:eat", + Scope: "flavor:vanilla", + }, + { + Permission: "ice_cream:eat", + Scope: "flavor:chocolate", + }, + }, + } + v2 := accesscontrol.RoleDTO{ + OrgID: 1, + Name: "grafana:tests:fake", + Version: 2, + Permissions: []accesscontrol.Permission{ + { + Permission: "ice_cream:eat", + Scope: "flavor:vanilla", + }, + { + Permission: "ice_cream:serve", + Scope: "flavor:mint", + }, + { + Permission: "candy.liquorice:eat", + Scope: "", + }, + }, + } + + t.Run("create role", func(t *testing.T) { + id, err := s.createOrUpdateRole( + context.Background(), + v1, + nil, + ) + require.NoError(t, err) + assert.NotZero(t, id) + + p, err := s.Store.GetRole(context.Background(), 1, id) + require.NoError(t, err) + + lookup := permissionMap(p.Permissions) + assert.Contains(t, lookup, permissionTuple{ + Permission: "ice_cream:eat", + Scope: "flavor:vanilla", + }) + assert.Contains(t, lookup, permissionTuple{ + Permission: "ice_cream:eat", + Scope: "flavor:chocolate", + }) + + role := p.Role() + + t.Run("update to same version", func(t *testing.T) { + err := s.seed(context.Background(), 1, []accesscontrol.RoleDTO{v1}, nil) + require.NoError(t, err) + }) + t.Run("update to new role version", func(t *testing.T) { + err := s.seed(context.Background(), 1, []accesscontrol.RoleDTO{v2}, nil) + require.NoError(t, err) + + p, err := s.Store.GetRole(context.Background(), 1, role.ID) + require.NoError(t, err) + assert.Len(t, p.Permissions, len(v2.Permissions)) + + lookup := permissionMap(p.Permissions) + assert.Contains(t, lookup, permissionTuple{ + Permission: "candy.liquorice:eat", + Scope: "", + }) + assert.NotContains(t, lookup, permissionTuple{ + Permission: "ice_cream:eat", + Scope: "flavor:chocolate", + }) + }) + }) +} diff --git a/pkg/services/accesscontrol/testing/common.go b/pkg/services/accesscontrol/testing/common.go new file mode 100644 index 00000000000..b9539d15f21 --- /dev/null +++ b/pkg/services/accesscontrol/testing/common.go @@ -0,0 +1,121 @@ +package testing + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +type RoleTestCase struct { + Name string + UID string + Permissions []PermissionTestCase +} + +type PermissionTestCase struct { + Permission string + Scope string +} + +func CreateRole(t *testing.T, ac accesscontrol.Store, p RoleTestCase) *accesscontrol.RoleDTO { + createRoleCmd := accesscontrol.CreateRoleWithPermissionsCommand{ + OrgID: 1, + UID: p.UID, + Name: p.Name, + Permissions: []accesscontrol.Permission{}, + } + for _, perm := range p.Permissions { + createRoleCmd.Permissions = append(createRoleCmd.Permissions, accesscontrol.Permission{ + Permission: perm.Permission, + Scope: perm.Scope, + }) + } + + res, err := ac.CreateRoleWithPermissions(context.Background(), createRoleCmd) + require.NoError(t, err) + + return res +} + +func CreateUserWithRole(t *testing.T, db *sqlstore.SQLStore, ac accesscontrol.Store, user string, roles []RoleTestCase) { + createUserCmd := models.CreateUserCommand{ + Email: user + "@test.com", + Name: user, + Login: user, + OrgId: 1, + } + + u, err := db.CreateUser(context.Background(), createUserCmd) + require.NoError(t, err) + userId := u.Id + + for _, p := range roles { + createRoleCmd := accesscontrol.CreateRoleCommand{ + OrgID: 1, + Name: p.Name, + } + res, err := ac.CreateRole(context.Background(), createRoleCmd) + require.NoError(t, err) + roleId := res.ID + + for _, perm := range p.Permissions { + permCmd := accesscontrol.CreatePermissionCommand{ + RoleID: roleId, + Permission: perm.Permission, + Scope: perm.Scope, + } + + _, err := ac.CreatePermission(context.Background(), permCmd) + require.NoError(t, err) + } + + addUserRoleCmd := accesscontrol.AddUserRoleCommand{ + OrgID: 1, + RoleID: roleId, + UserID: userId, + } + err = ac.AddUserRole(&addUserRoleCmd) + require.NoError(t, err) + } +} + +func CreateTeamWithRole(t *testing.T, db *sqlstore.SQLStore, ac accesscontrol.Store, teamname string, roles []RoleTestCase) { + email, orgID := teamname+"@test.com", int64(1) + team, err := db.CreateTeam(teamname, email, orgID) + require.NoError(t, err) + teamId := team.Id + + for _, p := range roles { + createRoleCmd := accesscontrol.CreateRoleCommand{ + OrgID: orgID, + Name: p.Name, + } + res, err := ac.CreateRole(context.Background(), createRoleCmd) + require.NoError(t, err) + roleId := res.ID + + for _, perm := range p.Permissions { + permCmd := accesscontrol.CreatePermissionCommand{ + RoleID: roleId, + Permission: perm.Permission, + Scope: perm.Scope, + } + + _, err := ac.CreatePermission(context.Background(), permCmd) + require.NoError(t, err) + } + + addTeamRoleCmd := accesscontrol.AddTeamRoleCommand{ + OrgID: 1, + RoleID: roleId, + TeamID: teamId, + } + err = ac.AddTeamRole(&addTeamRoleCmd) + require.NoError(t, err) + } +} diff --git a/pkg/services/accesscontrol/testing/common_bench.go b/pkg/services/accesscontrol/testing/common_bench.go new file mode 100644 index 00000000000..ac5f48d50b0 --- /dev/null +++ b/pkg/services/accesscontrol/testing/common_bench.go @@ -0,0 +1,109 @@ +package testing + +import ( + "context" + "fmt" + "math" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +const ( + usernamePrefix = "user" + teamPrefix = "team" + PermissionsPerRole = 10 + UsersPerTeam = 10 +) + +func GenerateRoles(b *testing.B, db *sqlstore.SQLStore, ac accesscontrol.Store, rolesPerUser, users int) { + numberOfTeams := int(math.Ceil(float64(users) / UsersPerTeam)) + globalUserId := 0 + for i := 0; i < numberOfTeams; i++ { + // Create team + teamName := fmt.Sprintf("%s%v", teamPrefix, i) + teamEmail := fmt.Sprintf("%s@test.com", teamName) + team, err := db.CreateTeam(teamName, teamEmail, 1) + require.NoError(b, err) + teamId := team.Id + + // Create team roles + for j := 0; j < rolesPerUser; j++ { + roleName := fmt.Sprintf("role_%s_%v", teamName, j) + createRoleCmd := accesscontrol.CreateRoleCommand{OrgID: 1, Name: roleName} + res, err := ac.CreateRole(context.Background(), createRoleCmd) + require.NoError(b, err) + roleId := res.ID + + for k := 0; k < PermissionsPerRole; k++ { + permission := fmt.Sprintf("permission_%v", k) + scope := fmt.Sprintf("scope_%v", k) + permCmd := accesscontrol.CreatePermissionCommand{ + RoleID: roleId, + Permission: permission, + Scope: scope, + } + + _, err := ac.CreatePermission(context.Background(), permCmd) + require.NoError(b, err) + } + + addTeamRoleCmd := accesscontrol.AddTeamRoleCommand{ + OrgID: 1, + RoleID: roleId, + TeamID: teamId, + } + err = ac.AddTeamRole(&addTeamRoleCmd) + require.NoError(b, err) + } + + // Create team users + for u := 0; u < UsersPerTeam; u++ { + userName := fmt.Sprintf("%s%v", usernamePrefix, globalUserId) + userEmail := fmt.Sprintf("%s@test.com", userName) + createUserCmd := models.CreateUserCommand{Email: userEmail, Name: userName, Login: userName, OrgId: 1} + + user, err := db.CreateUser(context.Background(), createUserCmd) + require.NoError(b, err) + userId := user.Id + globalUserId++ + + // Create user roles + for j := 0; j < rolesPerUser; j++ { + roleName := fmt.Sprintf("role_%s_%v", userName, j) + createRoleCmd := accesscontrol.CreateRoleCommand{OrgID: 1, Name: roleName} + res, err := ac.CreateRole(context.Background(), createRoleCmd) + require.NoError(b, err) + roleId := res.ID + + for k := 0; k < PermissionsPerRole; k++ { + permission := fmt.Sprintf("permission_%v", k) + scope := fmt.Sprintf("scope_%v", k) + permCmd := accesscontrol.CreatePermissionCommand{ + RoleID: roleId, + Permission: permission, + Scope: scope, + } + + _, err := ac.CreatePermission(context.Background(), permCmd) + require.NoError(b, err) + } + + addUserRoleCmd := accesscontrol.AddUserRoleCommand{ + OrgID: 1, + RoleID: roleId, + UserID: userId, + } + err = ac.AddUserRole(&addUserRoleCmd) + require.NoError(b, err) + } + + err = db.AddTeamMember(userId, 1, teamId, false, 1) + require.NoError(b, err) + } + } +} diff --git a/scripts/benchmark-access-control.sh b/scripts/benchmark-access-control.sh new file mode 100755 index 00000000000..d6ac0b36da5 --- /dev/null +++ b/scripts/benchmark-access-control.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +COMMIT_HASH=$(git log -n 1 | grep -Po "(?<=commit )[0-9a-z]{40}") +COMMIT_HASH=${COMMIT_HASH::7} +BENCH_FILE="tmp/bench_${COMMIT_HASH}.txt" +BENCH_GRAPH="tmp/bench_${COMMIT_HASH}.html" + +# Run benchmark +go test -benchmem -run=^$ -bench . github.com/grafana/grafana/pkg/services/accesscontrol/database | tee "${BENCH_FILE}" + +CHART_DATA_USERS=$(grep -oP "^BenchmarkRolesUsers([^[:blank:]]+)[[:blank:]]+[0-9]+[[:blank:]]+[0-9]+" "${BENCH_FILE}" | + sed -E 's/Benchmark([^[:blank:]]+)[[:blank:]]+[0-9]+[[:blank:]]+([0-9]+)/\1 \2/' | + sed -E 's/^[[:alpha:]]+([0-9]+)_([0-9]+)-[0-9]+[[:blank:]]+(.*)/\2 \3/' | + sed -E 's/([^[:blank:]]+)[[:blank:]]+([^[:blank:]]+)/\[\1, \2],\n/' +) + +CHART_DATA_ROLES=$(grep -oP "^BenchmarkRolesPerUser([^[:blank:]]+)[[:blank:]]+[0-9]+[[:blank:]]+[0-9]+" "${BENCH_FILE}" | + sed -E 's/Benchmark([^[:blank:]]+)[[:blank:]]+[0-9]+[[:blank:]]+([0-9]+)/\1 \2/' | + sed -E 's/^[[:alpha:]]+([0-9]+)_([0-9]+)-[0-9]+[[:blank:]]+(.*)/\1 \3/' | + sed -E 's/([^[:blank:]]+)[[:blank:]]+([^[:blank:]]+)/\[\1, \2],\n/' +) + +HTML_CHART=" + + + + + +
+
+
+
+ + +" + +echo "${HTML_CHART}" | tee "${BENCH_GRAPH}"