diff --git a/.changelog/3568.txt b/.changelog/3568.txt new file mode 100644 index 0000000000..7cd775d0b6 --- /dev/null +++ b/.changelog/3568.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +resource/mongodbatlas_cloud_user_project_assignment +``` diff --git a/.github/workflows/acceptance-tests-runner.yml b/.github/workflows/acceptance-tests-runner.yml index 9e8d83f48e..6d2bfd47b5 100644 --- a/.github/workflows/acceptance-tests-runner.yml +++ b/.github/workflows/acceptance-tests-runner.yml @@ -305,6 +305,7 @@ jobs: - 'internal/service/controlplaneipaddresses/*.go' cloud_user: - 'internal/service/clouduserorgassignment/*.go' + - 'internal/service/clouduserprojectassignment/*.go' - 'internal/service/clouduserteamassignment/*.go' cluster: - 'internal/service/cluster/*.go' @@ -601,8 +602,10 @@ jobs: MONGODB_ATLAS_PROJECT_OWNER_ID: ${{ inputs.mongodb_atlas_project_owner_id }} MONGODB_ATLAS_ORG_ID: ${{ inputs.mongodb_atlas_org_id }} MONGODB_ATLAS_USERNAME: ${{ vars.MONGODB_ATLAS_USERNAME }} + MONGODB_ATLAS_USERNAME_2: ${{ vars.MONGODB_ATLAS_USERNAME_2 }} ACCTEST_PACKAGES: | ./internal/service/clouduserorgassignment + ./internal/service/clouduserprojectassignment ./internal/service/clouduserteamassignment run: make testacc diff --git a/internal/provider/provider.go b/internal/provider/provider.go index c7817cc362..a6fda8ac4d 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -30,6 +30,7 @@ import ( "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/apikeyprojectassignment" "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/atlasuser" "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/clouduserorgassignment" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/clouduserprojectassignment" "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/clouduserteamassignment" "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/controlplaneipaddresses" "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/databaseuser" @@ -491,6 +492,7 @@ func (p *MongodbtlasProvider) Resources(context.Context) []func() resource.Resou resourcepolicy.Resource, clouduserorgassignment.Resource, apikeyprojectassignment.Resource, + clouduserprojectassignment.Resource, teamprojectassignment.Resource, clouduserteamassignment.Resource, advancedclustertpf.Resource, diff --git a/internal/service/clouduserprojectassignment/main_test.go b/internal/service/clouduserprojectassignment/main_test.go new file mode 100644 index 0000000000..1f5e9c216e --- /dev/null +++ b/internal/service/clouduserprojectassignment/main_test.go @@ -0,0 +1,15 @@ +package clouduserprojectassignment_test + +import ( + "os" + "testing" + + "github.com/mongodb/terraform-provider-mongodbatlas/internal/testutil/acc" +) + +func TestMain(m *testing.M) { + cleanup := acc.SetupSharedResources() + exitCode := m.Run() + cleanup() + os.Exit(exitCode) +} diff --git a/internal/service/clouduserprojectassignment/model.go b/internal/service/clouduserprojectassignment/model.go new file mode 100644 index 0000000000..db19104c13 --- /dev/null +++ b/internal/service/clouduserprojectassignment/model.go @@ -0,0 +1,98 @@ +package clouduserprojectassignment + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/conversion" + + "go.mongodb.org/atlas-sdk/v20250312006/admin" +) + +func NewTFModel(ctx context.Context, projectID string, apiResp *admin.GroupUserResponse) (*TFModel, diag.Diagnostics) { + diags := diag.Diagnostics{} + + if apiResp == nil { + return nil, diags + } + + roles := conversion.TFSetValueOrNull(ctx, &apiResp.Roles, types.StringType) + + return &TFModel{ + Country: types.StringPointerValue(apiResp.Country), + CreatedAt: types.StringPointerValue(conversion.TimePtrToStringPtr(apiResp.CreatedAt)), + FirstName: types.StringPointerValue(apiResp.FirstName), + ProjectId: types.StringValue(projectID), + UserId: types.StringValue(apiResp.GetId()), + InvitationCreatedAt: types.StringPointerValue(conversion.TimePtrToStringPtr(apiResp.InvitationCreatedAt)), + InvitationExpiresAt: types.StringPointerValue(conversion.TimePtrToStringPtr(apiResp.InvitationExpiresAt)), + InviterUsername: types.StringPointerValue(apiResp.InviterUsername), + LastAuth: types.StringPointerValue(conversion.TimePtrToStringPtr(apiResp.LastAuth)), + LastName: types.StringPointerValue(apiResp.LastName), + MobileNumber: types.StringPointerValue(apiResp.MobileNumber), + OrgMembershipStatus: types.StringValue(apiResp.GetOrgMembershipStatus()), + Roles: roles, + Username: types.StringValue(apiResp.GetUsername()), + }, diags +} + +func NewProjectUserReq(ctx context.Context, plan *TFModel) (*admin.GroupUserRequest, diag.Diagnostics) { + roleNames := []string{} + if !plan.Roles.IsNull() && !plan.Roles.IsUnknown() { + roleNames = conversion.TypesSetToString(ctx, plan.Roles) + } + + addProjectUserReq := admin.GroupUserRequest{ + Username: plan.Username.ValueString(), + Roles: roleNames, + } + return &addProjectUserReq, nil +} + +func NewAtlasUpdateReq(ctx context.Context, plan *TFModel, currentRoles []string) (addRequests, removeRequests []*admin.AddOrRemoveGroupRole, diags diag.Diagnostics) { + var desiredRoles []string + if !plan.Roles.IsNull() && !plan.Roles.IsUnknown() { + desiredRoles = conversion.TypesSetToString(ctx, plan.Roles) + } + + rolesToAdd, rolesToRemove := diffRoles(currentRoles, desiredRoles) + + addRequests = make([]*admin.AddOrRemoveGroupRole, 0, len(rolesToAdd)) + for _, role := range rolesToAdd { + addRequests = append(addRequests, &admin.AddOrRemoveGroupRole{ + GroupRole: role, + }) + } + + removeRequests = make([]*admin.AddOrRemoveGroupRole, 0, len(rolesToRemove)) + for _, role := range rolesToRemove { + removeRequests = append(removeRequests, &admin.AddOrRemoveGroupRole{ + GroupRole: role, + }) + } + + return addRequests, removeRequests, nil +} + +func diffRoles(oldRoles, newRoles []string) (toAdd, toRemove []string) { + oldRolesMap := make(map[string]bool, len(oldRoles)) + + for _, role := range oldRoles { + oldRolesMap[role] = true + } + + for _, role := range newRoles { + if oldRolesMap[role] { + delete(oldRolesMap, role) + } else { + toAdd = append(toAdd, role) + } + } + + for role := range oldRolesMap { + toRemove = append(toRemove, role) + } + + return toAdd, toRemove +} diff --git a/internal/service/clouduserprojectassignment/model_test.go b/internal/service/clouduserprojectassignment/model_test.go new file mode 100644 index 0000000000..1bb7b0261d --- /dev/null +++ b/internal/service/clouduserprojectassignment/model_test.go @@ -0,0 +1,283 @@ +package clouduserprojectassignment_test + +import ( + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/clouduserprojectassignment" + "github.com/stretchr/testify/assert" + + "go.mongodb.org/atlas-sdk/v20250312006/admin" +) + +const ( + testUserID = "user-123" + testUsername = "jdoe" + testFirstName = "John" + testLastName = "Doe" + testCountry = "CA" + testMobile = "+1555123456" + testInviter = "admin" + testOrgMembershipStatus = "ACTIVE" + testInviterUsername = "" + + testProjectRoleOwner = "PROJECT_OWNER" + testProjectRoleRead = "PROJECT_READ_ONLY" + testProjectRoleMember = "PROJECT_MEMBER" + + testProjectID = "project-123" + testOrgID = "org-123" +) + +var ( + when = time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + testCreatedAt = when.Format(time.RFC3339) + testInvitationCreatedAt = when.Add(-24 * time.Hour).Format(time.RFC3339) + testInvitationExpiresAt = when.Add(24 * time.Hour).Format(time.RFC3339) + testLastAuth = when.Add(-2 * time.Hour).Format(time.RFC3339) + + testProjectRoles = []string{testProjectRoleMember, testProjectRoleOwner} +) + +type sdkToTFModelTestCase struct { + SDKResp *admin.GroupUserResponse + expectedTFModel *clouduserprojectassignment.TFModel +} + +func TestCloudUserProjectAssignmentSDKToTFModel(t *testing.T) { + ctx := t.Context() + + fullResp := &admin.GroupUserResponse{ + Id: testUserID, + Username: testUsername, + FirstName: admin.PtrString(testFirstName), + LastName: admin.PtrString(testLastName), + Country: admin.PtrString(testCountry), + MobileNumber: admin.PtrString(testMobile), + OrgMembershipStatus: testOrgMembershipStatus, + CreatedAt: admin.PtrTime(when), + LastAuth: admin.PtrTime(when.Add(-2 * time.Hour)), + InvitationCreatedAt: admin.PtrTime(when.Add(-24 * time.Hour)), + InvitationExpiresAt: admin.PtrTime(when.Add(24 * time.Hour)), + InviterUsername: admin.PtrString(testInviterUsername), + Roles: testProjectRoles, + } + + expectedRoles, _ := types.SetValueFrom(ctx, types.StringType, testProjectRoles) + + expectedFullModel := &clouduserprojectassignment.TFModel{ + UserId: types.StringValue(testUserID), + Username: types.StringValue(testUsername), + ProjectId: types.StringValue(testProjectID), + FirstName: types.StringValue(testFirstName), + LastName: types.StringValue(testLastName), + Country: types.StringValue(testCountry), + MobileNumber: types.StringValue(testMobile), + OrgMembershipStatus: types.StringValue(testOrgMembershipStatus), + CreatedAt: types.StringValue(testCreatedAt), + LastAuth: types.StringValue(testLastAuth), + InvitationCreatedAt: types.StringValue(testInvitationCreatedAt), + InvitationExpiresAt: types.StringValue(testInvitationExpiresAt), + InviterUsername: types.StringValue(testInviterUsername), + Roles: expectedRoles, + } + + testCases := map[string]sdkToTFModelTestCase{ + "nil SDK response": { + SDKResp: nil, + expectedTFModel: nil, + }, + "Complete SDK response": { + SDKResp: fullResp, + expectedTFModel: expectedFullModel, + }, + "Empty SDK response": { + SDKResp: &admin.GroupUserResponse{ + Id: "", + Username: "", + FirstName: nil, + LastName: nil, + Country: nil, + MobileNumber: nil, + OrgMembershipStatus: "", + CreatedAt: nil, + LastAuth: nil, + InvitationCreatedAt: nil, + InvitationExpiresAt: nil, + InviterUsername: nil, + Roles: nil, + }, + expectedTFModel: &clouduserprojectassignment.TFModel{ + UserId: types.StringValue(""), + Username: types.StringValue(""), + ProjectId: types.StringValue(testProjectID), + FirstName: types.StringNull(), + LastName: types.StringNull(), + Country: types.StringNull(), + MobileNumber: types.StringNull(), + OrgMembershipStatus: types.StringValue(""), + CreatedAt: types.StringNull(), + LastAuth: types.StringNull(), + InvitationCreatedAt: types.StringNull(), + InvitationExpiresAt: types.StringNull(), + InviterUsername: types.StringNull(), + Roles: types.SetNull(types.StringType), + }, + }, + } + + for testName, tc := range testCases { + t.Run(testName, func(t *testing.T) { + resultModel, diags := clouduserprojectassignment.NewTFModel(t.Context(), testProjectID, tc.SDKResp) + assert.False(t, diags.HasError(), "expected no diagnostics") + assert.Equal(t, tc.expectedTFModel, resultModel, "TFModel did not match expected") + }) + } +} + +func TestNewProjectUserRequest(t *testing.T) { + ctx := t.Context() + expectedRoles, _ := types.SetValueFrom(ctx, types.StringType, testProjectRoles) + + testCases := map[string]struct { + plan *clouduserprojectassignment.TFModel + expected *admin.GroupUserRequest + }{ + "Complete model": { + plan: &clouduserprojectassignment.TFModel{ + UserId: types.StringValue(testUserID), + Username: types.StringValue(testUsername), + ProjectId: types.StringValue(testProjectID), + FirstName: types.StringValue(testFirstName), + LastName: types.StringValue(testLastName), + Country: types.StringValue(testCountry), + MobileNumber: types.StringValue(testMobile), + OrgMembershipStatus: types.StringValue(testOrgMembershipStatus), + CreatedAt: types.StringValue(testCreatedAt), + LastAuth: types.StringValue(testLastAuth), + InvitationCreatedAt: types.StringValue(testInvitationCreatedAt), + InvitationExpiresAt: types.StringValue(testInvitationExpiresAt), + InviterUsername: types.StringValue(testInviterUsername), + Roles: expectedRoles, + }, + expected: &admin.GroupUserRequest{ + Username: testUsername, + Roles: testProjectRoles, + }, + }, + "Nil model": { + plan: &clouduserprojectassignment.TFModel{ + Username: types.StringNull(), + Roles: types.SetNull(types.StringType), + }, + expected: &admin.GroupUserRequest{ + Username: "", + Roles: []string{}, + }, + }, + "Empty model": { + plan: &clouduserprojectassignment.TFModel{ + Username: types.StringValue(""), + Roles: types.SetValueMust(types.StringType, []attr.Value{}), + }, + expected: &admin.GroupUserRequest{ + Username: "", + Roles: []string{}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + req, diags := clouduserprojectassignment.NewProjectUserReq(ctx, tc.plan) + assert.False(t, diags.HasError(), "expected no diagnostics") + assert.Equal(t, tc.expected, req) + }) + } +} + +func TestNewAtlasUpdateReq(t *testing.T) { + ctx := t.Context() + + type args struct { + stateRoles []string + planRoles []string + } + tests := []struct { + name string + args args + wantAddRoles []string + wantRemoveRoles []string + }{ + { + name: "add and remove roles", + args: args{ + stateRoles: []string{"GROUP_READ_ONLY", "GROUP_DATA_ACCESS_READ_ONLY"}, + planRoles: []string{"GROUP_OWNER", "GROUP_DATA_ACCESS_READ_ONLY"}, + }, + wantAddRoles: []string{"GROUP_OWNER"}, + wantRemoveRoles: []string{"GROUP_READ_ONLY"}, + }, + { + name: "no changes", + args: args{ + stateRoles: []string{"GROUP_OWNER"}, + planRoles: []string{"GROUP_OWNER"}, + }, + wantAddRoles: []string{}, + wantRemoveRoles: []string{}, + }, + { + name: "all roles removed", + args: args{ + stateRoles: []string{"GROUP_OWNER"}, + planRoles: []string{}, + }, + wantAddRoles: []string{}, + wantRemoveRoles: []string{"GROUP_OWNER"}, + }, + { + name: "all roles added", + args: args{ + stateRoles: []string{}, + planRoles: []string{"GROUP_OWNER"}, + }, + wantAddRoles: []string{"GROUP_OWNER"}, + wantRemoveRoles: []string{}, + }, + { + name: "nil roles", + args: args{ + stateRoles: nil, + planRoles: []string{}, + }, + wantAddRoles: []string{}, + wantRemoveRoles: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + planRoles, _ := types.SetValueFrom(ctx, types.StringType, tt.args.planRoles) + + state := tt.args.stateRoles + plan := &clouduserprojectassignment.TFModel{Roles: planRoles} + + addReqs, removeReqs, diags := clouduserprojectassignment.NewAtlasUpdateReq(ctx, plan, state) + assert.False(t, diags.HasError(), "expected no diagnostics") + + var gotAddRoles, gotRemoveRoles []string + for _, r := range addReqs { + gotAddRoles = append(gotAddRoles, r.GroupRole) + } + for _, r := range removeReqs { + gotRemoveRoles = append(gotRemoveRoles, r.GroupRole) + } + + assert.ElementsMatch(t, tt.wantAddRoles, gotAddRoles, "add roles mismatch") + assert.ElementsMatch(t, tt.wantRemoveRoles, gotRemoveRoles, "remove roles mismatch") + }) + } +} diff --git a/internal/service/clouduserprojectassignment/resource.go b/internal/service/clouduserprojectassignment/resource.go new file mode 100644 index 0000000000..eb99e3f389 --- /dev/null +++ b/internal/service/clouduserprojectassignment/resource.go @@ -0,0 +1,249 @@ +package clouduserprojectassignment + +import ( + "context" + "fmt" + "net/http" + "regexp" + + "go.mongodb.org/atlas-sdk/v20250312006/admin" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/conversion" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/common/validate" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/config" +) + +const ( + resourceName = "cloud_user_project_assignment" + errorReadingUser = "Error retrieving project users" +) + +var _ resource.ResourceWithConfigure = &rs{} +var _ resource.ResourceWithImportState = &rs{} + +func Resource() resource.Resource { + return &rs{ + RSCommon: config.RSCommon{ + ResourceName: resourceName, + }, + } +} + +type rs struct { + config.RSCommon +} + +func (r *rs) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = resourceSchema(ctx) + conversion.UpdateSchemaDescription(&resp.Schema) +} + +func (r *rs) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan TFModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + connV2 := r.Client.AtlasV2 + projectID := plan.ProjectId.ValueString() + projectUserRequest, diags := NewProjectUserReq(ctx, &plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + apiResp, _, err := connV2.MongoDBCloudUsersApi.AddProjectUser(ctx, projectID, projectUserRequest).Execute() + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("error assigning user to ProjectID(%s):", projectID), err.Error()) + return + } + + newCloudUserProjectAssignmentModel, diags := NewTFModel(ctx, projectID, apiResp) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, newCloudUserProjectAssignmentModel)...) +} + +func fetchProjectUser(ctx context.Context, connV2 *admin.APIClient, projectID, userID, username string) (*admin.GroupUserResponse, error) { + var userResp *admin.GroupUserResponse + var httpResp *http.Response + var err error + if userID != "" { + userResp, httpResp, err = connV2.MongoDBCloudUsersApi.GetProjectUser(ctx, projectID, userID).Execute() + if err != nil { + if validate.StatusNotFound(httpResp) { + return nil, nil + } + return nil, err + } + } else if username != "" { + var userListResp *admin.PaginatedGroupUser + params := &admin.ListProjectUsersApiParams{ + GroupId: projectID, + Username: &username, + } + userListResp, httpResp, err = connV2.MongoDBCloudUsersApi.ListProjectUsersWithParams(ctx, params).Execute() + if err != nil { + if validate.StatusNotFound(httpResp) { + return nil, nil + } + return nil, err + } + if userListResp == nil || len(userListResp.GetResults()) == 0 { + return nil, nil + } + userResp = &userListResp.GetResults()[0] + } + + return userResp, nil +} + +func (r *rs) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state TFModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + connV2 := r.Client.AtlasV2 + projectID := state.ProjectId.ValueString() + var userResp *admin.GroupUserResponse + var err error + + userID := state.UserId.ValueString() + username := state.Username.ValueString() + + userResp, err = fetchProjectUser(ctx, connV2, projectID, userID, username) + if err != nil { + resp.Diagnostics.AddError(errorReadingUser, err.Error()) + return + } + if userResp == nil { + resp.State.RemoveResource(ctx) + return + } + + newCloudUserProjectAssignmentModel, diags := NewTFModel(ctx, projectID, userResp) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, newCloudUserProjectAssignmentModel)...) +} + +func (r *rs) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan TFModel + var state TFModel + var err error + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + connV2 := r.Client.AtlasV2 + projectID := plan.ProjectId.ValueString() + userID := plan.UserId.ValueString() + username := plan.Username.ValueString() + + userInfo, _, err := connV2.MongoDBCloudUsersApi.GetProjectUser(ctx, projectID, userID).Execute() // Fetch current user roles from API (more reliable than state) + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("error fetching user(%s) from ProjectID(%s):", username, projectID), err.Error()) + return + } + + addRequests, removeRequests, diags := NewAtlasUpdateReq(ctx, &plan, userInfo.GetRoles()) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + for _, addReq := range addRequests { + _, _, err := connV2.MongoDBCloudUsersApi.AddProjectRole(ctx, projectID, userID, addReq).Execute() + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error adding role %s to user(%s) in ProjectID(%s):", addReq.GroupRole, username, projectID), + err.Error(), + ) + return + } + } + + for _, removeReq := range removeRequests { + _, _, err := connV2.MongoDBCloudUsersApi.RemoveProjectRole(ctx, projectID, userID, removeReq).Execute() + if err != nil { + resp.Diagnostics.AddError( + fmt.Sprintf("Error removing role %s from user(%s) in ProjectID(%s):", removeReq.GroupRole, username, projectID), + err.Error(), + ) + return + } + } + + var userResp *admin.GroupUserResponse + + if !state.UserId.IsNull() && state.UserId.ValueString() != "" { + userID := state.UserId.ValueString() + userResp, _, err = connV2.MongoDBCloudUsersApi.GetProjectUser(ctx, projectID, userID).Execute() + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("error fetching user(%s) from ProjectID(%s):", username, projectID), err.Error()) + return + } + } + + newCloudUserProjectAssignmentModel, diags := NewTFModel(ctx, projectID, userResp) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, newCloudUserProjectAssignmentModel)...) +} + +func (r *rs) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state *TFModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + connV2 := r.Client.AtlasV2 + projectID := state.ProjectId.ValueString() + userID := state.UserId.ValueString() + username := state.Username.ValueString() + + httpResp, err := connV2.MongoDBCloudUsersApi.RemoveProjectUser(ctx, projectID, userID).Execute() + if err != nil { + if validate.StatusNotFound(httpResp) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError(fmt.Sprintf("error deleting user(%s) from ProjectID(%s):", username, projectID), err.Error()) + return + } +} + +func (r *rs) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + importID := req.ID + ok, parts := conversion.ImportSplit(req.ID, 2) + if !ok { + resp.Diagnostics.AddError("invalid import ID format", "expected 'project_id/user_id' or 'project_id/username', got: "+importID) + return + } + projectID, user := parts[0], parts[1] + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), projectID)...) + + emailRegex := regexp.MustCompile(`^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$`) + + if emailRegex.MatchString(user) { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("username"), user)...) + } else { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("user_id"), user)...) + } +} diff --git a/internal/service/clouduserprojectassignment/resource_migration_test.go b/internal/service/clouduserprojectassignment/resource_migration_test.go new file mode 100644 index 0000000000..7313818a43 --- /dev/null +++ b/internal/service/clouduserprojectassignment/resource_migration_test.go @@ -0,0 +1,138 @@ +package clouduserprojectassignment_test + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/testutil/acc" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/testutil/mig" +) + +const ( + resourceInvitationName = "mongodbatlas_project_invitation.mig_test" + resourceProjectName = "mongodbatlas_project.mig_test" + resourceUserProjectAssignmentName = "mongodbatlas_cloud_user_project_assignment.user_mig_test" +) + +func TestMigCloudUserProjectAssignmentRS_basic(t *testing.T) { + mig.SkipIfVersionBelow(t, "2.0.0") // when resource 1st released + mig.CreateAndRunTest(t, basicTestCase(t)) +} + +func TestMigCloudUserProjectAssignmentRS_migrationJourney(t *testing.T) { + var ( + orgID = os.Getenv("MONGODB_ATLAS_ORG_ID") + username = acc.RandomEmail() + projectName = fmt.Sprintf("mig_user_project_%s", acc.RandomName()) + roles = []string{"GROUP_READ_ONLY", "GROUP_DATA_ACCESS_READ_ONLY"} + ) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acc.PreCheckBasic(t) }, + CheckDestroy: checkDestroy, + Steps: []resource.TestStep{ + { + ExternalProviders: mig.ExternalProviders(), + Config: legacyProjectInvitationConfig(username, projectName, orgID, roles), + }, + { + ProtoV6ProviderFactories: acc.TestAccProviderV6Factories, + Config: userProjectAssignmentConfigSecond(username, projectName, orgID, roles), + Check: checksSecond(username, roles), + }, + { + ProtoV6ProviderFactories: acc.TestAccProviderV6Factories, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(resourceInvitationName, plancheck.ResourceActionDestroy), + }, + }, + Config: removeProjectInvitationConfigThird(username, projectName, orgID, roles), + }, + mig.TestStepCheckEmptyPlan(removeProjectInvitationConfigThird(username, projectName, orgID, roles)), + }, + }) +} + +func legacyProjectInvitationConfig(username, projectName, orgID string, roles []string) string { + rolesStr := `"` + strings.Join(roles, `", "`) + `"` + config := fmt.Sprintf(` + locals { + username = %[1]q + roles = [%[2]s] + } + + resource "mongodbatlas_project" "mig_test" { + name = %[3]q + org_id = %[4]q + } + + resource "mongodbatlas_project_invitation" "mig_test" { + project_id = mongodbatlas_project.mig_test.id + username = local.username + roles = local.roles + } + `, username, rolesStr, projectName, orgID) + return config +} + +func userProjectAssignmentConfigSecond(username, projectName, orgID string, roles []string) string { + rolesStr := `"` + strings.Join(roles, `", "`) + `"` + return fmt.Sprintf(` + locals { + username = %[1]q + roles = [%[2]s] + } + + resource "mongodbatlas_project" "mig_test" { + name = %[3]q + org_id = %[4]q + } + + resource "mongodbatlas_project_invitation" "mig_test" { + project_id = mongodbatlas_project.mig_test.id + username = local.username + roles = local.roles + } + + resource "mongodbatlas_cloud_user_project_assignment" "user_mig_test" { + project_id = mongodbatlas_project.mig_test.id + username = local.username + roles = local.roles + } + `, username, rolesStr, projectName, orgID) +} + +func removeProjectInvitationConfigThird(username, projectName, orgID string, roles []string) string { + rolesStr := `"` + strings.Join(roles, `", "`) + `"` + return fmt.Sprintf(` + locals { + username = %[1]q + roles = [%[2]s] + } + + resource "mongodbatlas_project" "mig_test" { + name = %[3]q + org_id = %[4]q + } + + resource "mongodbatlas_cloud_user_project_assignment" "user_mig_test" { + project_id = mongodbatlas_project.mig_test.id + username = local.username + roles = local.roles + } + `, username, rolesStr, projectName, orgID) +} + +func checksSecond(username string, roles []string) resource.TestCheckFunc { + checkFuncs := []resource.TestCheckFunc{ + resource.TestCheckResourceAttr(resourceUserProjectAssignmentName, "username", username), + resource.TestCheckResourceAttrSet(resourceUserProjectAssignmentName, "project_id"), + resource.TestCheckResourceAttr(resourceUserProjectAssignmentName, "roles.#", fmt.Sprintf("%d", len(roles))), + } + return resource.ComposeAggregateTestCheckFunc(checkFuncs...) +} diff --git a/internal/service/clouduserprojectassignment/resource_schema.go b/internal/service/clouduserprojectassignment/resource_schema.go new file mode 100644 index 0000000000..f3fbb3eb01 --- /dev/null +++ b/internal/service/clouduserprojectassignment/resource_schema.go @@ -0,0 +1,124 @@ +package clouduserprojectassignment + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-plugin-framework/resource/schema" +) + +func resourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "country": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Two-character alphabetical string that identifies the MongoDB Cloud user's geographic location. This parameter uses the ISO 3166-1a2 code format.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "created_at": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Date and time when MongoDB Cloud created the current account. This value is in the ISO 8601 timestamp format in UTC.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "first_name": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "First or given name that belongs to the MongoDB Cloud user.", + }, + "project_id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Unique 24-hexadecimal digit string that identifies your project. Use the [/groups](#tag/Projects/operation/listProjects) endpoint to retrieve all projects to which the authenticated user has access.\n\n**NOTE**: Groups and projects are synonymous terms. Your group id is the same as your project id. For existing groups, your group/project id remains the same. The resource and corresponding endpoints use the term groups.", + }, + "user_id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique 24-hexadecimal digit string that identifies the MongoDB Cloud user.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "invitation_created_at": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Date and time when MongoDB Cloud sent the invitation. MongoDB Cloud represents this timestamp in ISO 8601 format in UTC.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "invitation_expires_at": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Date and time when the invitation from MongoDB Cloud expires. MongoDB Cloud represents this timestamp in ISO 8601 format in UTC.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "inviter_username": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Username of the MongoDB Cloud user who sent the invitation to join the organization.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "last_auth": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Date and time when the current account last authenticated. This value is in the ISO 8601 timestamp format in UTC.", + }, + "last_name": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Last name, family name, or surname that belongs to the MongoDB Cloud user.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "mobile_number": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Mobile phone number that belongs to the MongoDB Cloud user.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "org_membership_status": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "String enum that indicates whether the MongoDB Cloud user has a pending invitation to join the organization or they are already active in the organization.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "roles": schema.SetAttribute{ + ElementType: types.StringType, + Required: true, + MarkdownDescription: "One or more project-level roles to assign the MongoDB Cloud user.", + Validators: []validator.Set{ + setvalidator.SizeAtLeast(1), + }, + }, + "username": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Email address that represents the username of the MongoDB Cloud user.", + }, + }, + } +} + +type TFModel struct { + Country types.String `tfsdk:"country"` + CreatedAt types.String `tfsdk:"created_at"` + FirstName types.String `tfsdk:"first_name"` + ProjectId types.String `tfsdk:"project_id"` + UserId types.String `tfsdk:"user_id"` + InvitationCreatedAt types.String `tfsdk:"invitation_created_at"` + InvitationExpiresAt types.String `tfsdk:"invitation_expires_at"` + InviterUsername types.String `tfsdk:"inviter_username"` + LastAuth types.String `tfsdk:"last_auth"` + LastName types.String `tfsdk:"last_name"` + MobileNumber types.String `tfsdk:"mobile_number"` + OrgMembershipStatus types.String `tfsdk:"org_membership_status"` + Roles types.Set `tfsdk:"roles"` + Username types.String `tfsdk:"username"` +} diff --git a/internal/service/clouduserprojectassignment/resource_test.go b/internal/service/clouduserprojectassignment/resource_test.go new file mode 100644 index 0000000000..c3b7e60d6d --- /dev/null +++ b/internal/service/clouduserprojectassignment/resource_test.go @@ -0,0 +1,153 @@ +package clouduserprojectassignment_test + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/testutil/acc" +) + +var resourceNamePending = "mongodbatlas_cloud_user_project_assignment.test_pending" +var resourceNameActive = "mongodbatlas_cloud_user_project_assignment.test_active" + +func TestAccCloudUserProjectAssignmentRS_basic(t *testing.T) { + resource.ParallelTest(t, *basicTestCase(t)) +} + +func basicTestCase(t *testing.T) *resource.TestCase { + t.Helper() + + orgID := os.Getenv("MONGODB_ATLAS_ORG_ID") + activeUsername := os.Getenv("MONGODB_ATLAS_USERNAME_2") + pendingUsername := acc.RandomEmail() + projectName := acc.RandomName() + roles := []string{"GROUP_OWNER", "GROUP_CLUSTER_MANAGER"} + updatedRoles := []string{"GROUP_OWNER", "GROUP_SEARCH_INDEX_EDITOR", "GROUP_READ_ONLY"} + + return &resource.TestCase{ + PreCheck: func() { acc.PreCheckBasic(t); acc.PreCheckAtlasUsernames(t) }, + ProtoV6ProviderFactories: acc.TestAccProviderV6Factories, + CheckDestroy: checkDestroy, + Steps: []resource.TestStep{ + { + Config: configBasic(orgID, pendingUsername, activeUsername, projectName, roles), + Check: checks(pendingUsername, activeUsername, projectName, roles), + }, + { + Config: configBasic(orgID, pendingUsername, activeUsername, projectName, updatedRoles), + Check: checks(pendingUsername, activeUsername, projectName, updatedRoles), + }, + { + ResourceName: resourceNamePending, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "user_id", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + attrs := s.RootModule().Resources[resourceNamePending].Primary.Attributes + projectID := attrs["project_id"] + userID := attrs["user_id"] + return projectID + "/" + userID, nil + }, + }, + { + ResourceName: resourceNamePending, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "user_id", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + attrs := s.RootModule().Resources[resourceNamePending].Primary.Attributes + projectID := attrs["project_id"] + username := attrs["username"] + return projectID + "/" + username, nil + }, + }, + { + ResourceName: resourceNameActive, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "user_id", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + attrs := s.RootModule().Resources[resourceNameActive].Primary.Attributes + projectID := attrs["project_id"] + userID := attrs["user_id"] + return projectID + "/" + userID, nil + }, + }, + { + ResourceName: resourceNameActive, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "user_id", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + attrs := s.RootModule().Resources[resourceNameActive].Primary.Attributes + projectID := attrs["project_id"] + username := attrs["username"] + return projectID + "/" + username, nil + }, + }, + }, + } +} + +func configBasic(orgID, pendingUsername, activeUsername, projectName string, roles []string) string { + rolesStr := `"` + strings.Join(roles, `", "`) + `"` + return fmt.Sprintf(` + resource "mongodbatlas_project" "test" { + name = %[1]q + org_id = %[2]q + } + + resource "mongodbatlas_cloud_user_project_assignment" "test_pending" { + username = %[3]q + project_id = mongodbatlas_project.test.id + roles = [%[5]s] + } + + resource "mongodbatlas_cloud_user_project_assignment" "test_active" { + username = %[4]q + project_id = mongodbatlas_project.test.id + roles = [%[5]s] + }`, + projectName, orgID, pendingUsername, activeUsername, rolesStr) +} + +func checks(pendingUsername, activeUsername, projectName string, roles []string) resource.TestCheckFunc { + checkFuncs := []resource.TestCheckFunc{ + resource.TestCheckResourceAttr(resourceNamePending, "username", pendingUsername), + resource.TestCheckResourceAttrSet(resourceNamePending, "project_id"), + resource.TestCheckResourceAttr(resourceNamePending, "roles.#", fmt.Sprintf("%d", len(roles))), + resource.TestCheckResourceAttr(resourceNameActive, "username", activeUsername), + resource.TestCheckResourceAttrSet(resourceNameActive, "project_id"), + resource.TestCheckResourceAttr(resourceNameActive, "roles.#", fmt.Sprintf("%d", len(roles))), + } + + for _, role := range roles { + checkFuncs = append(checkFuncs, + resource.TestCheckTypeSetElemAttr(resourceNamePending, "roles.*", role), + resource.TestCheckTypeSetElemAttr(resourceNameActive, "roles.*", role), + ) + } + return resource.ComposeAggregateTestCheckFunc(checkFuncs...) +} + +func checkDestroy(s *terraform.State) error { + for _, r := range s.RootModule().Resources { + if r.Type != "mongodbatlas_cloud_user_project_assignment" { + continue + } + + userID := r.Primary.Attributes["user_id"] + projectID := r.Primary.Attributes["project_id"] + + _, _, err := acc.ConnV2().MongoDBCloudUsersApi.GetProjectUser(context.Background(), projectID, userID).Execute() + if err == nil { + return fmt.Errorf("cloud user project assignment for user (%s) in project (%s) still exists", userID, projectID) + } + } + return nil +} diff --git a/internal/service/clouduserprojectassignment/tfplugingen/generator_config.yml b/internal/service/clouduserprojectassignment/tfplugingen/generator_config.yml new file mode 100644 index 0000000000..c29f693a7d --- /dev/null +++ b/internal/service/clouduserprojectassignment/tfplugingen/generator_config.yml @@ -0,0 +1,17 @@ +provider: + name: mongodbatlas + +# TODO: Endpoints from Atlas Admin API must be specified for schema and model generation. Singular or plural data sources can be removed if not used. + +resources: + cloud_user_project_assignment: + read: + path: /api/atlas/v2/groups/{groupId}/users/{userId} + method: GET + create: + path: /api/atlas/v2/groups/{groupId}/users + method: POST + delete: + path: /api/atlas/v2/groups/{groupId}/users/{userId} + method: DELETE +