diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index 16ca6324ee0..41de06d0ac8 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -38,6 +38,8 @@ type Service interface { // DeclareFixedRoles allows the caller to declare, to the service, fixed roles and their // assignments to organization roles ("Viewer", "Editor", "Admin") or "Grafana Admin" DeclareFixedRoles(registrations ...RoleRegistration) error + // SaveExternalServiceRole creates or updates an external service's role and assigns it to a given service account id. + SaveExternalServiceRole(ctx context.Context, cmd SaveExternalServiceRoleCommand) error //IsDisabled returns if access control is enabled or not IsDisabled() bool } diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index 9c64d8a9257..e2bcfd3fc94 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -62,6 +62,7 @@ type store interface { SearchUsersPermissions(ctx context.Context, orgID int64, options accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) GetUsersBasicRoles(ctx context.Context, userFilter []int64, orgID int64) (map[int64][]string, error) DeleteUserPermissions(ctx context.Context, orgID, userID int64) error + SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error } // Service is the service implementing role based access control. @@ -107,11 +108,11 @@ func (s *Service) getUserPermissions(ctx context.Context, user *user.SignedInUse } dbPermissions, err := s.store.GetUserPermissions(ctx, accesscontrol.GetUserPermissionsQuery{ - OrgID: user.OrgID, - UserID: user.UserID, - Roles: accesscontrol.GetOrgRoles(user), - TeamIDs: user.Teams, - RolePrefix: accesscontrol.ManagedRolePrefix, + OrgID: user.OrgID, + UserID: user.UserID, + Roles: accesscontrol.GetOrgRoles(user), + TeamIDs: user.Teams, + RolePrefixes: []string{accesscontrol.ManagedRolePrefix, accesscontrol.ExternalServiceRolePrefix}, }) if err != nil { return nil, err @@ -414,3 +415,21 @@ func PermissionMatchesSearchOptions(permission accesscontrol.Permission, searchO } return strings.HasPrefix(permission.Action, searchOptions.ActionPrefix) } + +func (s *Service) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error { + // If accesscontrol is disabled no need to save the external service role + if accesscontrol.IsDisabled(s.cfg) { + return nil + } + + if !s.features.IsEnabled(featuremgmt.FlagExternalServiceAuth) { + s.log.Debug("registering external service role is behind a feature flag, enable it to use this feature.") + return nil + } + + if err := cmd.Validate(); err != nil { + return err + } + + return s.store.SaveExternalServiceRole(ctx, cmd) +} diff --git a/pkg/services/accesscontrol/acimpl/service_test.go b/pkg/services/accesscontrol/acimpl/service_test.go index 06ab9b18d3d..f4fce35c319 100644 --- a/pkg/services/accesscontrol/acimpl/service_test.go +++ b/pkg/services/accesscontrol/acimpl/service_test.go @@ -740,3 +740,88 @@ func TestPermissionCacheKey(t *testing.T) { }) } } + +func TestService_SaveExternalServiceRole(t *testing.T) { + type run struct { + cmd accesscontrol.SaveExternalServiceRoleCommand + wantErr bool + } + tests := []struct { + name string + runs []run + }{ + { + name: "can create a role", + runs: []run{ + { + cmd: accesscontrol.SaveExternalServiceRoleCommand{ + OrgID: 2, + ServiceAccountID: 2, + ExternalServiceID: "App 1", + Permissions: []accesscontrol.Permission{{Action: "users:read", Scope: "users:id:1"}}, + }, + wantErr: false, + }, + }, + }, + { + name: "can update a role", + runs: []run{ + { + cmd: accesscontrol.SaveExternalServiceRoleCommand{ + Global: true, + ServiceAccountID: 2, + ExternalServiceID: "App 1", + Permissions: []accesscontrol.Permission{{Action: "users:read", Scope: "users:id:1"}}, + }, + wantErr: false, + }, + { + cmd: accesscontrol.SaveExternalServiceRoleCommand{ + Global: true, + ServiceAccountID: 2, + ExternalServiceID: "App 1", + Permissions: []accesscontrol.Permission{ + {Action: "users:write", Scope: "users:id:1"}, + {Action: "users:write", Scope: "users:id:2"}, + }, + }, + wantErr: false, + }, + }, + }, + { + name: "test command validity - no service account ID", + runs: []run{ + { + cmd: accesscontrol.SaveExternalServiceRoleCommand{ + OrgID: 2, + ExternalServiceID: "App 1", + Permissions: []accesscontrol.Permission{{Action: "users:read", Scope: "users:id:1"}}, + }, + wantErr: true, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + ac := setupTestEnv(t) + ac.features = featuremgmt.WithFeatures(featuremgmt.FlagExternalServiceAuth) + for _, r := range tt.runs { + err := ac.SaveExternalServiceRole(ctx, r.cmd) + if r.wantErr { + require.Error(t, err) + continue + } + require.NoError(t, err) + + // Check that the permissions and assignment are stored correctly + perms, errGetPerms := ac.getUserPermissions(ctx, &user.SignedInUser{OrgID: r.cmd.OrgID, UserID: 2}, accesscontrol.Options{}) + require.NoError(t, errGetPerms) + assert.ElementsMatch(t, r.cmd.Permissions, perms) + } + }) + } +} diff --git a/pkg/services/accesscontrol/actest/fake.go b/pkg/services/accesscontrol/actest/fake.go index c394907ff07..f25f001a398 100644 --- a/pkg/services/accesscontrol/actest/fake.go +++ b/pkg/services/accesscontrol/actest/fake.go @@ -53,6 +53,10 @@ func (f FakeService) IsDisabled() bool { return f.ExpectedDisabled } +func (f FakeService) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error { + return f.ExpectedErr +} + var _ accesscontrol.AccessControl = new(FakeAccessControl) type FakeAccessControl struct { @@ -95,6 +99,10 @@ func (f FakeStore) DeleteUserPermissions(ctx context.Context, orgID, userID int6 return f.ExpectedErr } +func (f FakeStore) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error { + return f.ExpectedErr +} + var _ accesscontrol.PermissionsService = new(FakePermissionsService) type FakePermissionsService struct { diff --git a/pkg/services/accesscontrol/database/database.go b/pkg/services/accesscontrol/database/database.go index 2ecdecff5c9..612fa2651c6 100644 --- a/pkg/services/accesscontrol/database/database.go +++ b/pkg/services/accesscontrol/database/database.go @@ -35,9 +35,12 @@ func (s *AccessControlStore) GetUserPermissions(ctx context.Context, query acces INNER JOIN role ON role.id = permission.role_id ` + filter - if query.RolePrefix != "" { - q += " WHERE role.name LIKE ?" - params = append(params, query.RolePrefix+"%") + if len(query.RolePrefixes) > 0 { + q += " WHERE ( " + strings.Repeat("role.name LIKE ? OR ", len(query.RolePrefixes)) + q = q[:len(q)-4] + " )" // remove last " OR " + for i := range query.RolePrefixes { + params = append(params, query.RolePrefixes[i]+"%") + } } if err := sess.SQL(q, params...).Find(&result); err != nil { diff --git a/pkg/services/accesscontrol/database/externalservices.go b/pkg/services/accesscontrol/database/externalservices.go new file mode 100644 index 00000000000..bf724cd1572 --- /dev/null +++ b/pkg/services/accesscontrol/database/externalservices.go @@ -0,0 +1,198 @@ +package database + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" +) + +func (s *AccessControlStore) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error { + role := genExternalServiceRole(cmd) + assignment := genExternalServiceAssignment(cmd) + + return s.sql.WithDbSession(ctx, func(sess *db.Session) error { + // Create or update the role + existingRole, errSaveRole := s.saveRole(ctx, sess, &role) + if errSaveRole != nil { + return errSaveRole + } + // Assign role to service account + // We update the assignment before the permissions to avoid an edge case. + // If the role is assigned to another service account (which can only happen if the services have the same ID) + // and permissions are updated before the assignment, this would result in the other service account acquiring + // a different set of permissions. + assignment.RoleID = existingRole.ID + errSaveAssign := s.saveUserAssignment(ctx, sess, assignment) + if errSaveAssign != nil { + return errSaveAssign + } + // Update permissions + return s.savePermissions(ctx, sess, existingRole.ID, cmd.Permissions) + }) +} + +func genExternalServiceRole(cmd accesscontrol.SaveExternalServiceRoleCommand) accesscontrol.Role { + role := accesscontrol.Role{ + OrgID: cmd.OrgID, + Version: 1, + Name: fmt.Sprintf("%s%s:permissions", accesscontrol.ExternalServiceRolePrefix, cmd.ExternalServiceID), + UID: fmt.Sprintf("%s%s_permissions", accesscontrol.ExternalServiceRoleUIDPrefix, cmd.ExternalServiceID), + DisplayName: fmt.Sprintf("External Service %s Permissions", cmd.ExternalServiceID), + Description: fmt.Sprintf("External Service %s permissions", cmd.ExternalServiceID), + Group: "External Service", + Hidden: true, + Created: time.Now(), + Updated: time.Now(), + } + if cmd.Global { + role.OrgID = accesscontrol.GlobalOrgID + } + return role +} + +func genExternalServiceAssignment(cmd accesscontrol.SaveExternalServiceRoleCommand) accesscontrol.UserRole { + assignment := accesscontrol.UserRole{ + OrgID: cmd.OrgID, + UserID: cmd.ServiceAccountID, + Created: time.Now(), + } + if cmd.Global { + assignment.OrgID = accesscontrol.GlobalOrgID + } + return assignment +} + +func getRoleByUID(ctx context.Context, sess *db.Session, uid string) (*accesscontrol.Role, error) { + var role accesscontrol.Role + has, err := sess.Where("uid = ?", uid).Get(&role) + if err != nil { + return nil, err + } + if !has { + return nil, accesscontrol.ErrRoleNotFound + } + return &role, nil +} + +func getRoleAssignments(ctx context.Context, sess *db.Session, roleID int64) ([]accesscontrol.UserRole, error) { + var assignements []accesscontrol.UserRole + if err := sess.Where("role_id = ?", roleID).Find(&assignements); err != nil { + return nil, err + } + return assignements, nil +} + +func getRolePermissions(ctx context.Context, sess *db.Session, id int64) ([]accesscontrol.Permission, error) { + var permissions []accesscontrol.Permission + if err := sess.Where("role_id = ?", id).Find(&permissions); err != nil { + return nil, err + } + return permissions, nil +} + +func permissionDiff(previous, new []accesscontrol.Permission) (added, removed []accesscontrol.Permission) { + type key struct{ Action, Scope string } + prevMap := map[key]int64{} + for i := range previous { + prevMap[key{previous[i].Action, previous[i].Scope}] = previous[i].ID + } + newMap := map[key]int64{} + for i := range new { + newMap[key{new[i].Action, new[i].Scope}] = 0 + } + for i := range new { + key := key{new[i].Action, new[i].Scope} + if _, already := prevMap[key]; !already { + added = append(added, new[i]) + } else { + delete(prevMap, key) + } + } + + for p, id := range prevMap { + removed = append(removed, accesscontrol.Permission{ID: id, Action: p.Action, Scope: p.Scope}) + } + + return added, removed +} + +func (*AccessControlStore) saveRole(ctx context.Context, sess *db.Session, role *accesscontrol.Role) (*accesscontrol.Role, error) { + existingRole, err := getRoleByUID(ctx, sess, role.UID) + if err != nil && !errors.Is(err, accesscontrol.ErrRoleNotFound) { + return nil, err + } + + if existingRole == nil { + if _, err := sess.Insert(role); err != nil { + return nil, err + } + } else { + role.ID = existingRole.ID + role.Created = existingRole.Created + if _, err := sess.Where("id = ?", existingRole.ID).MustCols("org_id").Update(role); err != nil { + return nil, err + } + } + return getRoleByUID(ctx, sess, role.UID) +} + +func (*AccessControlStore) savePermissions(ctx context.Context, sess *db.Session, roleID int64, permissions []accesscontrol.Permission) error { + now := time.Now() + storedPermissions, err := getRolePermissions(ctx, sess, roleID) + if err != nil { + return err + } + added, removed := permissionDiff(storedPermissions, permissions) + if len(added) > 0 { + for i := range added { + added[i].RoleID = roleID + added[i].Created = now + added[i].Updated = now + } + if _, err := sess.Insert(&added); err != nil { + return err + } + } + if len(removed) > 0 { + ids := make([]int64, len(removed)) + for i := range removed { + ids[i] = removed[i].ID + } + count, err := sess.In("id", ids).Delete(&accesscontrol.Permission{}) + if err != nil { + return err + } + if count != int64(len(removed)) { + return errors.New("failed to delete permissions that have been removed from role") + } + } + return nil +} + +func (*AccessControlStore) saveUserAssignment(ctx context.Context, sess *db.Session, assignment accesscontrol.UserRole) error { + // alreadyAssigned checks if the assignment already exists without accounting for the organization + assignments, errGetAssigns := getRoleAssignments(ctx, sess, assignment.RoleID) + if errGetAssigns != nil { + return errGetAssigns + } + + if len(assignments) == 0 { + if _, errInsert := sess.Insert(&assignment); errInsert != nil { + return errInsert + } + return nil + } + + // Ensure the role was assigned only to this service account + if len(assignments) > 1 || assignments[0].UserID != assignment.UserID { + return errors.New("external service role assigned to another user or service account") + } + + // Ensure the assignment is in the correct organization + _, errUpdate := sess.Where("role_id = ? AND user_id = ?", assignment.RoleID, assignment.UserID).MustCols("org_id").Update(&assignment) + return errUpdate +} diff --git a/pkg/services/accesscontrol/database/externalservices_test.go b/pkg/services/accesscontrol/database/externalservices_test.go new file mode 100644 index 00000000000..fbf926ea784 --- /dev/null +++ b/pkg/services/accesscontrol/database/externalservices_test.go @@ -0,0 +1,191 @@ +package database + +import ( + "context" + "fmt" + "testing" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/stretchr/testify/require" +) + +func TestAccessControlStore_SaveExternalServiceRole(t *testing.T) { + type run struct { + cmd accesscontrol.SaveExternalServiceRoleCommand + wantErr bool + } + tests := []struct { + name string + runs []run + }{ + { + name: "create app role", + runs: []run{ + { + cmd: accesscontrol.SaveExternalServiceRoleCommand{ + ExternalServiceID: "app1", + Global: true, + ServiceAccountID: 1, + Permissions: []accesscontrol.Permission{ + {Action: "users:read", Scope: "users:id:1"}, + {Action: "users:read", Scope: "users:id:2"}, + }, + }, + wantErr: false, + }, + }, + }, + { + name: "update app role", + runs: []run{ + { + cmd: accesscontrol.SaveExternalServiceRoleCommand{ + ExternalServiceID: "app1", + Global: true, + ServiceAccountID: 1, + Permissions: []accesscontrol.Permission{ + {Action: "users:read", Scope: "users:id:1"}, + {Action: "users:read", Scope: "users:id:2"}, + }, + }, + }, + { + cmd: accesscontrol.SaveExternalServiceRoleCommand{ + ExternalServiceID: "app1", + Global: true, + ServiceAccountID: 1, + Permissions: []accesscontrol.Permission{ + {Action: "users:write", Scope: "users:id:1"}, + {Action: "users:write", Scope: "users:id:2"}, + }, + }, + }, + }, + }, + { + name: "allow switching role from local to global and back", + runs: []run{ + { + cmd: accesscontrol.SaveExternalServiceRoleCommand{ + ExternalServiceID: "app1", + OrgID: 1, + ServiceAccountID: 1, + Permissions: []accesscontrol.Permission{ + {Action: "users:read", Scope: "users:id:1"}, + {Action: "users:read", Scope: "users:id:2"}, + }, + }, + }, + { + cmd: accesscontrol.SaveExternalServiceRoleCommand{ + ExternalServiceID: "app1", + Global: true, + ServiceAccountID: 1, + Permissions: []accesscontrol.Permission{ + {Action: "users:read", Scope: "users:id:1"}, + {Action: "users:read", Scope: "users:id:2"}, + }, + }, + }, + { + cmd: accesscontrol.SaveExternalServiceRoleCommand{ + ExternalServiceID: "app1", + OrgID: 1, + ServiceAccountID: 1, + Permissions: []accesscontrol.Permission{ + {Action: "users:read", Scope: "users:id:1"}, + {Action: "users:read", Scope: "users:id:2"}, + }, + }, + }, + }, + }, + { + name: "edge case - remove all permissions", + runs: []run{ + { + cmd: accesscontrol.SaveExternalServiceRoleCommand{ + ExternalServiceID: "app1", + Global: true, + ServiceAccountID: 1, + Permissions: []accesscontrol.Permission{ + {Action: "users:read", Scope: "users:id:1"}, + {Action: "users:read", Scope: "users:id:2"}, + }, + }, + }, + { + cmd: accesscontrol.SaveExternalServiceRoleCommand{ + ExternalServiceID: "app1", + Global: true, + ServiceAccountID: 1, + Permissions: []accesscontrol.Permission{}, + }, + }, + }, + }, + { + name: "edge case - reassign to another service account", + runs: []run{ + { + cmd: accesscontrol.SaveExternalServiceRoleCommand{ + ExternalServiceID: "app1", + Global: true, + ServiceAccountID: 1, + }, + }, + { + cmd: accesscontrol.SaveExternalServiceRoleCommand{ + ExternalServiceID: "app1", + Global: true, + ServiceAccountID: 2, + }, + wantErr: true, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + s := &AccessControlStore{ + sql: db.InitTestDB(t), + } + + for i := range tt.runs { + err := s.SaveExternalServiceRole(ctx, tt.runs[i].cmd) + if tt.runs[i].wantErr { + require.Error(t, err) + continue + } + require.NoError(t, err) + + errDBSession := s.sql.WithDbSession(ctx, func(sess *db.Session) error { + storedRole, err := getRoleByUID(ctx, sess, fmt.Sprintf("externalservice_%s_permissions", tt.runs[i].cmd.ExternalServiceID)) + require.NoError(t, err) + require.NotNil(t, storedRole) + require.Equal(t, tt.runs[i].cmd.Global, storedRole.Global(), "Incorrect global state of the role") + require.Equal(t, tt.runs[i].cmd.OrgID, storedRole.OrgID, "Incorrect OrgID of the role") + + storedPerm, err := getRolePermissions(ctx, sess, storedRole.ID) + require.NoError(t, err) + for i := range storedPerm { + storedPerm[i] = accesscontrol.Permission{Action: storedPerm[i].Action, Scope: storedPerm[i].Scope} + } + require.ElementsMatch(t, tt.runs[i].cmd.Permissions, storedPerm) + + var assignment accesscontrol.UserRole + has, err := sess.Where("role_id = ? AND user_id = ?", storedRole.ID, tt.runs[i].cmd.ServiceAccountID).Get(&assignment) + require.NoError(t, err) + require.True(t, has) + require.Equal(t, tt.runs[i].cmd.Global, assignment.OrgID == accesscontrol.GlobalOrgID, "Incorrect global state of the assignment") + require.Equal(t, tt.runs[i].cmd.OrgID, assignment.OrgID, "Incorrect OrgID for the role assignment") + + return nil + }) + require.NoError(t, errDBSession) + } + }) + } +} diff --git a/pkg/services/accesscontrol/errors.go b/pkg/services/accesscontrol/errors.go index 27047abb596..5616b8583ab 100644 --- a/pkg/services/accesscontrol/errors.go +++ b/pkg/services/accesscontrol/errors.go @@ -11,6 +11,7 @@ var ( ErrInvalidScope = errors.New("invalid scope") ErrResolverNotFound = errors.New("no resolver found") ErrPluginIDRequired = errors.New("plugin ID is required") + ErrRoleNotFound = errors.New("role not found") ) type ErrorInvalidRole struct{} diff --git a/pkg/services/accesscontrol/mock/mock.go b/pkg/services/accesscontrol/mock/mock.go index 1ae451c0069..4b7bf250ccb 100644 --- a/pkg/services/accesscontrol/mock/mock.go +++ b/pkg/services/accesscontrol/mock/mock.go @@ -30,6 +30,7 @@ type Calls struct { DeleteUserPermissions []interface{} SearchUsersPermissions []interface{} SearchUserPermissions []interface{} + SaveExternalServiceRole []interface{} } type Mock struct { @@ -56,6 +57,7 @@ type Mock struct { DeleteUserPermissionsFunc func(context.Context, int64) error SearchUsersPermissionsFunc func(context.Context, *user.SignedInUser, int64, accesscontrol.SearchOptions) (map[int64][]accesscontrol.Permission, error) SearchUserPermissionsFunc func(ctx context.Context, orgID int64, searchOptions accesscontrol.SearchOptions) ([]accesscontrol.Permission, error) + SaveExternalServiceRoleFunc func(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error scopeResolvers accesscontrol.Resolvers } @@ -235,3 +237,12 @@ func (m *Mock) SearchUserPermissions(ctx context.Context, orgID int64, searchOpt } return nil, nil } + +func (m *Mock) SaveExternalServiceRole(ctx context.Context, cmd accesscontrol.SaveExternalServiceRoleCommand) error { + m.Calls.SaveExternalServiceRole = append(m.Calls.SaveExternalServiceRole, []interface{}{ctx, cmd}) + // Use override if provided + if m.SaveExternalServiceRoleFunc != nil { + return m.SaveExternalServiceRoleFunc(ctx, cmd) + } + return nil +} diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index ea688c67635..aadd63aea7f 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -2,10 +2,12 @@ package accesscontrol import ( "encoding/json" + "errors" "fmt" "strings" "time" + "github.com/grafana/grafana/pkg/infra/slugify" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/util/errutil" @@ -129,6 +131,10 @@ func (r *RoleDTO) IsBasic() bool { return strings.HasPrefix(r.Name, BasicRolePrefix) || strings.HasPrefix(r.UID, BasicRoleUIDPrefix) } +func (r *RoleDTO) IsExternalService() bool { + return strings.HasPrefix(r.Name, ExternalServiceRolePrefix) || strings.HasPrefix(r.UID, ExternalServiceRoleUIDPrefix) +} + func (r RoleDTO) MarshalJSON() ([]byte, error) { type Alias RoleDTO @@ -188,11 +194,11 @@ func (p Permission) OSSPermission() Permission { } type GetUserPermissionsQuery struct { - OrgID int64 - UserID int64 - Roles []string - TeamIDs []int64 - RolePrefix string + OrgID int64 + UserID int64 + Roles []string + TeamIDs []int64 + RolePrefixes []string } // ResourcePermission is structure that holds all actions that either a team / user / builtin-role @@ -245,14 +251,47 @@ type SetResourcePermissionCommand struct { Permission string `json:"permission"` } +type SaveExternalServiceRoleCommand struct { + OrgID int64 + Global bool + ExternalServiceID string + ServiceAccountID int64 + Permissions []Permission +} + +func (cmd *SaveExternalServiceRoleCommand) Validate() error { + if cmd.ExternalServiceID == "" { + return errors.New("external service id not specified") + } + + // slugify the external service id ID for the role to have correct name and uid + cmd.ExternalServiceID = slugify.Slugify(cmd.ExternalServiceID) + + if (cmd.OrgID == GlobalOrgID) != cmd.Global { + return fmt.Errorf("invalid org id %d for global role %t", cmd.OrgID, cmd.Global) + } + + if cmd.Permissions == nil || len(cmd.Permissions) == 0 { + return errors.New("no permissions provided") + } + + if cmd.ServiceAccountID <= 0 { + return fmt.Errorf("invalid service account id %d", cmd.ServiceAccountID) + } + + return nil +} + const ( - GlobalOrgID = 0 - FixedRolePrefix = "fixed:" - ManagedRolePrefix = "managed:" - BasicRolePrefix = "basic:" - PluginRolePrefix = "plugins:" - BasicRoleUIDPrefix = "basic_" - RoleGrafanaAdmin = "Grafana Admin" + GlobalOrgID = 0 + FixedRolePrefix = "fixed:" + ManagedRolePrefix = "managed:" + BasicRolePrefix = "basic:" + PluginRolePrefix = "plugins:" + ExternalServiceRolePrefix = "externalservice:" + BasicRoleUIDPrefix = "basic_" + ExternalServiceRoleUIDPrefix = "externalservice_" + RoleGrafanaAdmin = "Grafana Admin" GeneralFolderUID = "general" diff --git a/pkg/services/accesscontrol/models_test.go b/pkg/services/accesscontrol/models_test.go new file mode 100644 index 00000000000..a8503442f38 --- /dev/null +++ b/pkg/services/accesscontrol/models_test.go @@ -0,0 +1,80 @@ +package accesscontrol + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSaveExternalServiceRoleCommand_Validate(t *testing.T) { + tests := []struct { + name string + cmd SaveExternalServiceRoleCommand + wantID string + wantErr bool + }{ + { + name: "invalid global statement", + cmd: SaveExternalServiceRoleCommand{ + OrgID: 1, + Global: true, + ExternalServiceID: "app 1", + ServiceAccountID: 2, + Permissions: []Permission{{Action: "users:read", Scope: "users:id:1"}}, + }, + wantErr: true, + }, + { + name: "invalid no permissions", + cmd: SaveExternalServiceRoleCommand{ + OrgID: 1, + ExternalServiceID: "app 1", + ServiceAccountID: 2, + Permissions: []Permission{}, + }, + wantErr: true, + }, + { + name: "invalid service account id", + cmd: SaveExternalServiceRoleCommand{ + OrgID: 1, + ExternalServiceID: "app 1", + ServiceAccountID: -1, + Permissions: []Permission{{Action: "users:read", Scope: "users:id:1"}}, + }, + wantErr: true, + }, + { + name: "invalid no Ext Service ID", + cmd: SaveExternalServiceRoleCommand{ + OrgID: 1, + ServiceAccountID: 2, + Permissions: []Permission{{Action: "users:read", Scope: "users:id:1"}}, + }, + wantErr: true, + }, + { + name: "slugify the external service ID correctly", + cmd: SaveExternalServiceRoleCommand{ + ExternalServiceID: "ThisIs a Very Strange ___ App Name?", + Global: true, + ServiceAccountID: 2, + Permissions: []Permission{{Action: "users:read", Scope: "users:id:1"}}, + }, + wantErr: false, + wantID: "thisis-a-very-strange-app-name", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cmd.Validate() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + require.Equal(t, tt.wantID, tt.cmd.ExternalServiceID) + }) + } +}