From 164e28039283a2a59af1968b0d5f3121313e50de Mon Sep 17 00:00:00 2001 From: Aryan Bhokare <92683836+aryan-bhokare@users.noreply.github.com> Date: Mon, 22 Jul 2024 11:49:35 +0530 Subject: [PATCH] Multiple project owner backend (#4774) * Modified db schema of Owner. Signed-off-by: aryan * Added new API GetProjectOwners. Signed-off-by: aryan * fix: return type error. Signed-off-by: aryan * chore(deps): Bump golang.org/x/crypto in /chaoscenter/authentication (#4527) Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.18.0 to 0.21.0. - [Commits](https://github.com/golang/crypto/compare/v0.18.0...v0.21.0) --- updated-dependencies: - dependency-name: golang.org/x/crypto dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): Bump follow-redirects in /chaoscenter/web (#4529) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.5 to 1.15.6. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): Bump github.com/golang/protobuf (#4493) Bumps [github.com/golang/protobuf](https://github.com/golang/protobuf) from 1.5.3 to 1.5.4. - [Release notes](https://github.com/golang/protobuf/releases) - [Commits](https://github.com/golang/protobuf/compare/v1.5.3...v1.5.4) --- updated-dependencies: - dependency-name: github.com/golang/protobuf dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Raj Das * Modified SendInvitation API. This modification unables to send invite with the role as owner. Signed-off-by: aryan * Modified LeaveProject API. This modification checks if the User is the last owner of the project and if not User can leave the project. Signed-off-by: aryan * RBAC modification `LeaveProject`. Allows Owner to be able to leave the project. Signed-off-by: aryan * Added `UpdateMemberRole` API. This API is used for updating role of the member in the project. Signed-off-by: aryan * Fixed some syntax errors. Signed-off-by: aryan * Updated roles for owner. Signed-off-by: aryan * Added new API `DeleteProject`. Owner can delete project with help of this API. Signed-off-by: aryan * Added mocks. Signed-off-by: aryan * modified go.sum Signed-off-by: aryan * Added condition `UpdateMemberRole`. User cannot change role of their own, so that it will avoid edge cases like 1. User is the last owner of the project. 2. User accidentally losing owner access to the projects. Signed-off-by: aryan * made suggested changes. Signed-off-by: aryan * Changed DeleteProject endpoint to have url parameter. Signed-off-by: aryan * Minor fixes. Signed-off-by: aryan * fixed import orders Signed-off-by: aryan * fixing RoleEditor to RoleExecuter Signed-off-by: aryan --------- Signed-off-by: aryan Signed-off-by: dependabot[bot] Signed-off-by: Aryan Bhokare <92683836+aryan-bhokare@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Raj Das Co-authored-by: Saranya Jena --- .../api/handlers/rest/project_handler.go | 139 ++++++++++++++++++ .../authentication/api/mocks/rest_mocks.go | 15 ++ .../api/routes/project_router.go | 3 + .../authentication/pkg/entities/project.go | 12 +- .../authentication/pkg/project/repository.go | 59 ++++++++ .../pkg/services/project_service.go | 15 ++ .../authentication/pkg/validations/roles.go | 8 +- 7 files changed, 246 insertions(+), 5 deletions(-) diff --git a/chaoscenter/authentication/api/handlers/rest/project_handler.go b/chaoscenter/authentication/api/handlers/rest/project_handler.go index 33dc02c9530..142fd179be8 100644 --- a/chaoscenter/authentication/api/handlers/rest/project_handler.go +++ b/chaoscenter/authentication/api/handlers/rest/project_handler.go @@ -210,6 +210,29 @@ func GetActiveProjectMembers(service services.ApplicationService) gin.HandlerFun } } +// GetActiveProjectOwners godoc +// +// @Summary Get active project Owners. +// @Description Return list of active project owners. +// @Tags ProjectRouter +// @Param state path string true "State" +// @Accept json +// @Produce json +// @Failure 500 {object} response.ErrServerError +// @Success 200 {object} response.Response{} +// @Router /get_project_owners/:project_id/:state [get] +func GetActiveProjectOwners(service services.ApplicationService) gin.HandlerFunc { + return func(c *gin.Context) { + projectID := c.Param("project_id") + owners, err := service.GetProjectOwners(projectID) + if err != nil { + c.JSON(utils.ErrorStatusCodes[utils.ErrServerError], presenter.CreateErrorResponse(utils.ErrServerError)) + return + } + c.JSON(http.StatusOK, gin.H{"data": owners}) + } +} + // getInvitation returns the Invitation status func getInvitation(service services.ApplicationService, member entities.MemberInput) (entities.Invitation, error) { project, err := service.GetProjectByProjectID(member.ProjectID) @@ -618,6 +641,20 @@ func LeaveProject(service services.ApplicationService) gin.HandlerFunc { return } + if member.Role != nil && *member.Role == entities.RoleOwner { + owners, err := service.GetProjectOwners(member.ProjectID) + if err != nil { + log.Error(err) + c.JSON(utils.ErrorStatusCodes[utils.ErrServerError], presenter.CreateErrorResponse(utils.ErrServerError)) + return + } + + if len(owners) == 1 { + c.JSON(utils.ErrorStatusCodes[utils.ErrInvalidRequest], gin.H{"message": "Cannot leave project. There must be at least one owner."}) + return + } + } + // admin/user shouldn't be able to perform any task if it's default pwd is not changes(initial login is true) initialLogin, err := CheckInitialLogin(service, c.MustGet("uid").(string)) if err != nil { @@ -799,6 +836,67 @@ func UpdateProjectName(service services.ApplicationService) gin.HandlerFunc { } } +// UpdateMemberRole godoc +// +// @Summary Update member role. +// @Description Return updated member role. +// @Tags ProjectRouter +// @Accept json +// @Produce json +// @Failure 400 {object} response.ErrInvalidRequest +// @Failure 401 {object} response.ErrUnauthorized +// @Failure 500 {object} response.ErrServerError +// @Success 200 {object} response.Response{} +// @Router /update_member_role [post] +// +// UpdateMemberRole is used to update a member role in the project +func UpdateMemberRole(service services.ApplicationService) gin.HandlerFunc { + return func(c *gin.Context) { + var member entities.MemberInput + err := c.BindJSON(&member) + if err != nil { + log.Warn(err) + c.JSON(utils.ErrorStatusCodes[utils.ErrInvalidRequest], presenter.CreateErrorResponse(utils.ErrInvalidRequest)) + return + } + + // Validating member role + if member.Role == nil || (*member.Role != entities.RoleExecutor && *member.Role != entities.RoleViewer && *member.Role != entities.RoleOwner) { + c.JSON(utils.ErrorStatusCodes[utils.ErrInvalidRole], presenter.CreateErrorResponse(utils.ErrInvalidRole)) + return + } + + err = validations.RbacValidator(c.MustGet("uid").(string), + member.ProjectID, + validations.MutationRbacRules["updateMemberRole"], + string(entities.AcceptedInvitation), + service) + if err != nil { + log.Warn(err) + c.JSON(utils.ErrorStatusCodes[utils.ErrUnauthorized], + presenter.CreateErrorResponse(utils.ErrUnauthorized)) + return + } + + uid := c.MustGet("uid").(string) + if uid == member.UserID { + c.JSON(http.StatusBadRequest, gin.H{"message": "User cannot change their own role."}) + return + } + + err = service.UpdateMemberRole(member.ProjectID, member.UserID, member.Role) + if err != nil { + log.Error(err) + c.JSON(utils.ErrorStatusCodes[utils.ErrServerError], presenter.CreateErrorResponse(utils.ErrServerError)) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Successfully updated Role", + }) + } +} + // GetOwnerProjects godoc // // @Summary Get projects owner. @@ -869,3 +967,44 @@ func GetProjectRole(service services.ApplicationService) gin.HandlerFunc { } } + +// DeleteProject godoc +// +// @Description Delete a project. +// @Tags ProjectRouter +// @Accept json +// @Produce json +// @Failure 400 {object} response.ErrProjectNotFound +// @Failure 500 {object} response.ErrServerError +// @Success 200 {object} response.Response{} +// @Router /delete_project/{project_id} [post] +// +// DeleteProject is used to delete a project. +func DeleteProject(service services.ApplicationService) gin.HandlerFunc { + return func(c *gin.Context) { + projectID := c.Param("project_id") + + err := validations.RbacValidator(c.MustGet("uid").(string), + projectID, + validations.MutationRbacRules["deleteProject"], + string(entities.AcceptedInvitation), + service) + if err != nil { + log.Warn(err) + c.JSON(utils.ErrorStatusCodes[utils.ErrUnauthorized], + presenter.CreateErrorResponse(utils.ErrUnauthorized)) + return + } + + err = service.DeleteProject(projectID) + if err != nil { + log.Error(err) + c.JSON(utils.ErrorStatusCodes[utils.ErrServerError], presenter.CreateErrorResponse(utils.ErrServerError)) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Successfully deleted project.", + }) + } +} diff --git a/chaoscenter/authentication/api/mocks/rest_mocks.go b/chaoscenter/authentication/api/mocks/rest_mocks.go index 10d947c976c..bf9526ebbbc 100644 --- a/chaoscenter/authentication/api/mocks/rest_mocks.go +++ b/chaoscenter/authentication/api/mocks/rest_mocks.go @@ -127,6 +127,11 @@ func (m *MockedApplicationService) UpdateProjectName(projectID, projectName stri return args.Error(0) } +func (m *MockedApplicationService) UpdateMemberRole(projectID, userID string, role *entities.MemberRole) error { + args := m.Called(projectID, userID, role) + return args.Error(0) +} + func (m *MockedApplicationService) GetAggregateProjects(pipeline mongo.Pipeline, opts *options.AggregateOptions) (*mongo.Cursor, error) { args := m.Called(pipeline, opts) return args.Get(0).(*mongo.Cursor), args.Error(1) @@ -152,6 +157,11 @@ func (m *MockedApplicationService) GetProjectMembers(projectID, state string) ([ return args.Get(0).([]*entities.Member), args.Error(1) } +func (m *MockedApplicationService) GetProjectOwners(projectID string) ([]*entities.Member, error) { + args := m.Called(projectID) + return args.Get(0).([]*entities.Member), args.Error(1) +} + func (m *MockedApplicationService) ListInvitations(userID string, invitationState entities.Invitation) ([]*entities.Project, error) { args := m.Called(userID, invitationState) return args.Get(0).([]*entities.Project), args.Error(1) @@ -207,6 +217,11 @@ func (m *MockedApplicationService) RbacValidator(userID, resourceID string, rule return args.Error(0) } +func (m *MockedApplicationService) DeleteProject(projectID string) error { + args := m.Called(projectID) + return args.Error(0) +} + func (m *MockedApplicationService) CreateConfig(config authConfig.AuthConfig) error { args := m.Called(config) return args.Error(0) diff --git a/chaoscenter/authentication/api/routes/project_router.go b/chaoscenter/authentication/api/routes/project_router.go index f1c82e0077a..1f28d5e27c0 100644 --- a/chaoscenter/authentication/api/routes/project_router.go +++ b/chaoscenter/authentication/api/routes/project_router.go @@ -13,6 +13,7 @@ func ProjectRouter(router *gin.Engine, service services.ApplicationService) { router.Use(middleware.JwtMiddleware(service)) router.GET("/get_project/:project_id", rest.GetProject(service)) router.GET("/get_project_members/:project_id/:state", rest.GetActiveProjectMembers(service)) + router.GET("/get_project_owners/:project_id", rest.GetActiveProjectOwners(service)) router.GET("/get_user_with_project/:username", rest.GetUserWithProject(service)) router.GET("/get_owner_projects", rest.GetOwnerProjects(service)) router.GET("/get_project_role/:project_id", rest.GetProjectRole(service)) @@ -26,4 +27,6 @@ func ProjectRouter(router *gin.Engine, service services.ApplicationService) { router.POST("/remove_invitation", rest.RemoveInvitation(service)) router.POST("/leave_project", rest.LeaveProject(service)) router.POST("/update_project_name", rest.UpdateProjectName(service)) + router.POST("/update_member_role", rest.UpdateMemberRole(service)) + router.POST("/delete_project/:project_id", rest.DeleteProject(service)) } diff --git a/chaoscenter/authentication/pkg/entities/project.go b/chaoscenter/authentication/pkg/entities/project.go index 6aa35fe073f..dbe22bcf67e 100644 --- a/chaoscenter/authentication/pkg/entities/project.go +++ b/chaoscenter/authentication/pkg/entities/project.go @@ -10,9 +10,13 @@ type Project struct { } type Owner struct { - UserID string `bson:"user_id" json:"userID"` - Username string `bson:"username" json:"username"` + UserID string `bson:"user_id" json:"userID"` + Username string `bson:"username" json:"username"` + Invitation Invitation `bson:"invitation" json:"invitation"` + JoinedAt int64 `bson:"joined_at" json:"joinedAt"` + DeactivatedAt *int64 `bson:"deactivated_at,omitempty" json:"deactivatedAt,omitempty"` } + type MemberStat struct { Owner *[]Owner `bson:"owner" json:"owner"` Total int `bson:"total" json:"total"` @@ -50,6 +54,10 @@ type CreateProjectInput struct { UserID string `bson:"user_id" json:"userID"` } +type DeleteProjectInput struct { + ProjectID string `json:"projectID"` +} + type MemberInput struct { ProjectID string `json:"projectID"` UserID string `json:"userID"` diff --git a/chaoscenter/authentication/pkg/project/repository.go b/chaoscenter/authentication/pkg/project/repository.go index 461f1ecf299..9830e3129e5 100644 --- a/chaoscenter/authentication/pkg/project/repository.go +++ b/chaoscenter/authentication/pkg/project/repository.go @@ -25,11 +25,14 @@ type Repository interface { RemoveInvitation(projectID string, userID string, invitation entities.Invitation) error UpdateInvite(projectID string, userID string, invitation entities.Invitation, role *entities.MemberRole) error UpdateProjectName(projectID string, projectName string) error + UpdateMemberRole(projectID string, userID string, role *entities.MemberRole) error GetAggregateProjects(pipeline mongo.Pipeline, opts *options.AggregateOptions) (*mongo.Cursor, error) UpdateProjectState(ctx context.Context, userID string, deactivateTime int64, isDeactivate bool) error GetOwnerProjects(ctx context.Context, userID string) ([]*entities.Project, error) GetProjectRole(projectID string, userID string) (*entities.MemberRole, error) GetProjectMembers(projectID string, state string) ([]*entities.Member, error) + GetProjectOwners(projectID string) ([]*entities.Member, error) + DeleteProject(projectID string) error ListInvitations(userID string, invitationState entities.Invitation) ([]*entities.Project, error) } @@ -277,6 +280,24 @@ func (r repository) UpdateProjectName(projectID string, projectName string) erro return nil } +// UpdateMemberRole : Updates Role of the member in the project. +func (r repository) UpdateMemberRole(projectID string, userID string, role *entities.MemberRole) error { + opts := options.Update().SetArrayFilters(options.ArrayFilters{ + Filters: []interface{}{ + bson.D{{"elem.user_id", userID}}, + }, + }) + query := bson.D{{"_id", projectID}} + update := bson.D{{"$set", bson.M{"members.$[elem].role": role}}} + + _, err := r.Collection.UpdateOne(context.TODO(), query, update, opts) + if err != nil { + return err + } + + return nil +} + // GetAggregateProjects takes a mongo pipeline to retrieve the project details from the database func (r repository) GetAggregateProjects(pipeline mongo.Pipeline, opts *options.AggregateOptions) (*mongo.Cursor, error) { results, err := r.Collection.Aggregate(context.TODO(), pipeline, opts) @@ -381,6 +402,28 @@ func (r repository) GetOwnerProjects(ctx context.Context, userID string) ([]*ent return projects, nil } +// GetProjectOwners takes projectID and returns the owners +func (r repository) GetProjectOwners(projectID string) ([]*entities.Member, error) { + filter := bson.D{{"_id", projectID}} + + var project struct { + Members []*entities.Member `bson:"members"` + } + err := r.Collection.FindOne(context.TODO(), filter).Decode(&project) + if err != nil { + return nil, err + } + + // Filter the members to include only the owners + var owners []*entities.Member + for _, member := range project.Members { + if member.Role == entities.RoleOwner && member.Invitation == entities.AcceptedInvitation { + owners = append(owners, member) + } + } + return owners, nil +} + // GetProjectRole returns the role of a user in the project func (r repository) GetProjectRole(projectID string, userID string) (*entities.MemberRole, error) { filter := bson.D{ @@ -556,3 +599,19 @@ func NewRepo(collection *mongo.Collection) Repository { Collection: collection, } } + +// DeleteProject deletes the project with given projectID +func (r repository) DeleteProject(projectID string) error { + query := bson.D{{"_id", projectID}} + + result, err := r.Collection.DeleteOne(context.TODO(), query) + if err != nil { + return err + } + + if result.DeletedCount == 0 { + return errors.New("no project found with the given projectID") + } + + return nil +} diff --git a/chaoscenter/authentication/pkg/services/project_service.go b/chaoscenter/authentication/pkg/services/project_service.go index 664f4a20e81..3c5316b091f 100644 --- a/chaoscenter/authentication/pkg/services/project_service.go +++ b/chaoscenter/authentication/pkg/services/project_service.go @@ -20,11 +20,14 @@ type projectService interface { RemoveInvitation(projectID string, userID string, invitation entities.Invitation) error UpdateInvite(projectID string, userID string, invitation entities.Invitation, role *entities.MemberRole) error UpdateProjectName(projectID string, projectName string) error + UpdateMemberRole(projectID string, userID string, role *entities.MemberRole) error GetAggregateProjects(pipeline mongo.Pipeline, opts *options.AggregateOptions) (*mongo.Cursor, error) UpdateProjectState(ctx context.Context, userID string, deactivateTime int64, isDeactivate bool) error GetOwnerProjectIDs(ctx context.Context, userID string) ([]*entities.Project, error) GetProjectRole(projectID string, userID string) (*entities.MemberRole, error) GetProjectMembers(projectID string, state string) ([]*entities.Member, error) + GetProjectOwners(projectID string) ([]*entities.Member, error) + DeleteProject(projectID string) error ListInvitations(userID string, invitationState entities.Invitation) ([]*entities.Project, error) } @@ -64,6 +67,10 @@ func (a applicationService) UpdateProjectName(projectID string, projectName stri return a.projectRepository.UpdateProjectName(projectID, projectName) } +func (a applicationService) UpdateMemberRole(projectID string, userID string, role *entities.MemberRole) error { + return a.projectRepository.UpdateMemberRole(projectID, userID, role) +} + func (a applicationService) GetAggregateProjects(pipeline mongo.Pipeline, opts *options.AggregateOptions) (*mongo.Cursor, error) { return a.projectRepository.GetAggregateProjects(pipeline, opts) } @@ -82,6 +89,14 @@ func (a applicationService) GetProjectMembers(projectID string, state string) ([ return a.projectRepository.GetProjectMembers(projectID, state) } +func (a applicationService) GetProjectOwners(projectID string) ([]*entities.Member, error) { + return a.projectRepository.GetProjectOwners(projectID) +} + func (a applicationService) ListInvitations(userID string, invitationState entities.Invitation) ([]*entities.Project, error) { return a.projectRepository.ListInvitations(userID, invitationState) } + +func (a applicationService) DeleteProject(projectID string) error { + return a.projectRepository.DeleteProject(projectID) +} diff --git a/chaoscenter/authentication/pkg/validations/roles.go b/chaoscenter/authentication/pkg/validations/roles.go index 17319ef1f57..12ee0a18039 100644 --- a/chaoscenter/authentication/pkg/validations/roles.go +++ b/chaoscenter/authentication/pkg/validations/roles.go @@ -4,11 +4,13 @@ import "github.com/litmuschaos/litmus/chaoscenter/authentication/pkg/entities" var MutationRbacRules = map[string][]string{ "sendInvitation": {string(entities.RoleOwner)}, - "acceptInvitation": {string(entities.RoleViewer), string(entities.RoleExecutor)}, - "declineInvitation": {string(entities.RoleViewer), + "acceptInvitation": {string(entities.RoleOwner), string(entities.RoleViewer), string(entities.RoleExecutor)}, + "declineInvitation": {string(entities.RoleOwner), string(entities.RoleViewer), string(entities.RoleExecutor)}, "removeInvitation": {string(entities.RoleOwner)}, - "leaveProject": {string(entities.RoleViewer), string(entities.RoleExecutor)}, + "leaveProject": {string(entities.RoleOwner), string(entities.RoleViewer), string(entities.RoleExecutor)}, "updateProjectName": {string(entities.RoleOwner)}, + "updateMemberRole": {string(entities.RoleOwner)}, + "deleteProject": {string(entities.RoleOwner)}, "getProject": {string(entities.RoleOwner), string(entities.RoleViewer), string(entities.RoleExecutor)}, }