diff --git a/.changelog/3502.txt b/.changelog/3502.txt new file mode 100644 index 0000000000..7fecf382e9 --- /dev/null +++ b/.changelog/3502.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +resource/mongodbatlas_cloud_user_team_assignment +``` \ No newline at end of file diff --git a/.github/workflows/acceptance-tests-runner.yml b/.github/workflows/acceptance-tests-runner.yml index 17cd82f759..c283540b71 100644 --- a/.github/workflows/acceptance-tests-runner.yml +++ b/.github/workflows/acceptance-tests-runner.yml @@ -302,6 +302,7 @@ jobs: - 'internal/service/controlplaneipaddresses/*.go' cloud_user: - 'internal/service/clouduserorgassignment/*.go' + - 'internal/service/clouduserteamassignment/*.go' cluster: - 'internal/service/cluster/*.go' cluster_outage_simulation: @@ -592,7 +593,7 @@ jobs: MONGODB_ATLAS_LAST_VERSION: ${{ needs.get-provider-version.outputs.provider_version }} ACCTEST_PACKAGES: ./internal/service/controlplaneipaddresses run: make testacc - + cloud_user: needs: [ change-detection, get-provider-version ] if: ${{ needs.change-detection.outputs.cloud_user == 'true' || inputs.test_group == 'cloud_user' }} @@ -613,9 +614,13 @@ jobs: env: MONGODB_ATLAS_LAST_VERSION: ${{ needs.get-provider-version.outputs.provider_version }} MONGODB_ATLAS_TEAMS_IDS: ${{ inputs.mongodb_atlas_teams_ids }} - ACCTEST_PACKAGES: ./internal/service/clouduserorgassignment + MONGODB_ATLAS_PROJECT_OWNER_ID: ${{ inputs.mongodb_atlas_project_owner_id }} + MONGODB_ATLAS_ORG_ID: ${{ inputs.mongodb_atlas_org_id }} + ACCTEST_PACKAGES: | + ./internal/service/clouduserorgassignment + ./internal/service/clouduserteamassignment run: make testacc - + cluster: needs: [ change-detection, get-provider-version ] if: ${{ needs.change-detection.outputs.cluster == 'true' || inputs.test_group == 'cluster' }} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0d985e2fc5..82abd32f35 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/clouduserteamassignment" "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/controlplaneipaddresses" "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/databaseuser" "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/encryptionatrest" @@ -487,6 +488,7 @@ func (p *MongodbtlasProvider) Resources(context.Context) []func() resource.Resou resourcepolicy.Resource, clouduserorgassignment.Resource, apikeyprojectassignment.Resource, + clouduserteamassignment.Resource, } if config.PreviewProviderV2AdvancedCluster() { resources = append(resources, advancedclustertpf.Resource) diff --git a/internal/service/clouduserteamassignment/main_test.go b/internal/service/clouduserteamassignment/main_test.go new file mode 100644 index 0000000000..e63b13fa75 --- /dev/null +++ b/internal/service/clouduserteamassignment/main_test.go @@ -0,0 +1,15 @@ +package clouduserteamassignment_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/clouduserteamassignment/model.go b/internal/service/clouduserteamassignment/model.go new file mode 100644 index 0000000000..980e5ee850 --- /dev/null +++ b/internal/service/clouduserteamassignment/model.go @@ -0,0 +1,109 @@ +package clouduserteamassignment + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "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/v20250312005/admin" +) + +func NewTFUserTeamAssignmentModel(ctx context.Context, apiResp *admin.OrgUserResponse) (*TFUserTeamAssignmentModel, diag.Diagnostics) { + diags := diag.Diagnostics{} + var rolesObj types.Object + var rolesDiags diag.Diagnostics + + if apiResp == nil { + return nil, diags + } + + rolesObj, rolesDiags = NewTFRolesModel(ctx, &apiResp.Roles) + diags.Append(rolesDiags...) + + userTeamAssignment := TFUserTeamAssignmentModel{ + UserId: types.StringValue(apiResp.GetId()), + Username: types.StringValue(apiResp.GetUsername()), + OrgMembershipStatus: types.StringValue(apiResp.GetOrgMembershipStatus()), + Roles: rolesObj, + InvitationCreatedAt: types.StringPointerValue(conversion.TimePtrToStringPtr(apiResp.InvitationCreatedAt)), + InvitationExpiresAt: types.StringPointerValue(conversion.TimePtrToStringPtr(apiResp.InvitationExpiresAt)), + InviterUsername: types.StringPointerValue(apiResp.InviterUsername), + Country: types.StringPointerValue(apiResp.Country), + FirstName: types.StringPointerValue(apiResp.FirstName), + LastName: types.StringPointerValue(apiResp.LastName), + CreatedAt: types.StringPointerValue(conversion.TimePtrToStringPtr(apiResp.CreatedAt)), + LastAuth: types.StringPointerValue(conversion.TimePtrToStringPtr(apiResp.LastAuth)), + MobileNumber: types.StringPointerValue(apiResp.MobileNumber), + } + + userTeamAssignment.TeamIDs = types.SetNull(types.StringType) + if apiResp.TeamIds != nil { + userTeamAssignment.TeamIDs, diags = types.SetValueFrom(ctx, types.StringType, apiResp.TeamIds) + if diags.HasError() { + return nil, diags + } + } + + return &userTeamAssignment, nil +} + +func NewTFRolesModel(ctx context.Context, roles *admin.OrgUserRolesResponse) (types.Object, diag.Diagnostics) { + diags := diag.Diagnostics{} + + if roles == nil { + return types.ObjectNull(RolesObjectAttrTypes), diags + } + + var orgRoles types.Set + if roles.OrgRoles == nil || len(*roles.OrgRoles) == 0 { + orgRoles = types.SetNull(types.StringType) + } else { + orgRoles, _ = types.SetValueFrom(ctx, types.StringType, *roles.OrgRoles) + } + + projectRoleAssignmentsSet := NewTFProjectRoleAssignments(ctx, roles.GroupRoleAssignments) + + rolesObj, _ := types.ObjectValue( + RolesObjectAttrTypes, + map[string]attr.Value{ + "project_role_assignments": projectRoleAssignmentsSet, + "org_roles": orgRoles, + }, + ) + + return rolesObj, diags +} + +func NewTFProjectRoleAssignments(ctx context.Context, groupRoleAssignments *[]admin.GroupRoleAssignment) types.Set { + if groupRoleAssignments == nil { + return types.SetNull(ProjectRoleAssignmentsAttrType) + } + + var projectRoleAssignments []TFProjectRoleAssignmentsModel + + for _, pra := range *groupRoleAssignments { + projectID := types.StringPointerValue(pra.GroupId) + var projectRoles types.Set + if pra.GroupRoles == nil || len(*pra.GroupRoles) == 0 { + projectRoles = types.SetNull(types.StringType) + } else { + projectRoles, _ = types.SetValueFrom(ctx, types.StringType, pra.GroupRoles) + } + projectRoleAssignments = append(projectRoleAssignments, TFProjectRoleAssignmentsModel{ + ProjectId: projectID, + ProjectRoles: projectRoles, + }) + } + + praSet, _ := types.SetValueFrom(ctx, ProjectRoleAssignmentsAttrType.ElemType.(types.ObjectType), projectRoleAssignments) + return praSet +} + +func NewUserTeamAssignmentReq(ctx context.Context, plan *TFUserTeamAssignmentModel) (*admin.AddOrRemoveUserFromTeam, diag.Diagnostics) { + addOrRemoveUserFromTeam := admin.AddOrRemoveUserFromTeam{ + Id: plan.UserId.ValueString(), + } + return &addOrRemoveUserFromTeam, nil +} diff --git a/internal/service/clouduserteamassignment/model_test.go b/internal/service/clouduserteamassignment/model_test.go new file mode 100644 index 0000000000..c3ff0633c6 --- /dev/null +++ b/internal/service/clouduserteamassignment/model_test.go @@ -0,0 +1,186 @@ +package clouduserteamassignment_test + +import ( + "context" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/mongodb/terraform-provider-mongodbatlas/internal/service/clouduserteamassignment" + "github.com/stretchr/testify/assert" + "go.mongodb.org/atlas-sdk/v20250312005/admin" +) + +const ( + testUserID = "user-123" + testUsername = "jdoe" + testFirstName = "John" + testLastName = "Doe" + testCountry = "CA" + testMobile = "+1555123456" + testInviter = "admin" + testOrgMembershipStatus = "ACTIVE" + testInviterUsername = "" + + testOrgRoleOwner = "ORG_OWNER" + testOrgRoleMember = "ORG_MEMBER" + testProjectRoleOwner = "PROJECT_OWNER" + testProjectRoleRead = "PROJECT_READ_ONLY" + testProjectRoleMember = "PROJECT_MEMBER" + + testTeamID1 = "team1" + testTeamID2 = "team2" + testProjectID1 = "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) + + testTeamIDs = []string{"team1", "team2"} + testOrgRoles = []string{"owner", "readWrite"} +) + +type sdkToTFModelTestCase struct { + SDKResp *admin.OrgUserResponse + expectedTFModel *clouduserteamassignment.TFUserTeamAssignmentModel +} + +func TestUserTeamAssignmentSDKToTFModel(t *testing.T) { + ctx := t.Context() + + fullResp := &admin.OrgUserResponse{ + 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), + TeamIds: &testTeamIDs, + Roles: admin.OrgUserRolesResponse{ + OrgRoles: &testOrgRoles, + }, + } + + orgRolesSet, _ := types.SetValueFrom(ctx, types.StringType, testOrgRoles) + expectedRoles, _ := types.ObjectValue(clouduserteamassignment.RolesObjectAttrTypes, map[string]attr.Value{ + "org_roles": orgRolesSet, + "project_role_assignments": types.ListNull(clouduserteamassignment.ProjectRoleAssignmentsAttrType), + }) + expectedTeams, _ := types.SetValueFrom(ctx, types.StringType, testTeamIDs) + expectedFullModel := &clouduserteamassignment.TFUserTeamAssignmentModel{ + UserId: types.StringValue(testUserID), + Username: types.StringValue(testUsername), + 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), + OrgId: types.StringNull(), + TeamId: types.StringNull(), + Roles: expectedRoles, + TeamIDs: expectedTeams, + } + + testCases := map[string]sdkToTFModelTestCase{ + "nil SDK response": { + SDKResp: nil, + expectedTFModel: nil, + }, + "Complete SDK response": { + SDKResp: fullResp, + expectedTFModel: expectedFullModel, + }, + } + + for testName, tc := range testCases { + t.Run(testName, func(t *testing.T) { + resultModel, diags := clouduserteamassignment.NewTFUserTeamAssignmentModel(t.Context(), tc.SDKResp) + assert.False(t, diags.HasError(), "expected no diagnostics") + assert.Equal(t, tc.expectedTFModel, resultModel, "created terraform model did not match expected output") + }) + } +} + +func createRolesObject(ctx context.Context, orgRoles []string, projectAssignments []clouduserteamassignment.TFProjectRoleAssignmentsModel) types.Object { + orgRolesSet, _ := types.SetValueFrom(ctx, types.StringType, orgRoles) + + var projectRoleAssignmentsList types.List + if len(projectAssignments) == 0 { + projectRoleAssignmentsList = types.ListNull(clouduserteamassignment.ProjectRoleAssignmentsAttrType.ElemType.(types.ObjectType)) + } else { + projectRoleAssignmentsList, _ = types.ListValueFrom(ctx, clouduserteamassignment.ProjectRoleAssignmentsAttrType.ElemType.(types.ObjectType), projectAssignments) + } + + obj, _ := types.ObjectValue( + clouduserteamassignment.RolesObjectAttrTypes, + map[string]attr.Value{ + "org_roles": orgRolesSet, + "project_role_assignments": projectRoleAssignmentsList, + }, + ) + return obj +} + +func TestNewUserTeamAssignmentReq(t *testing.T) { + ctx := t.Context() + projectAssignment := clouduserteamassignment.TFProjectRoleAssignmentsModel{ + ProjectId: types.StringValue(testProjectID1), + ProjectRoles: types.SetValueMust(types.StringType, []attr.Value{types.StringValue(testProjectRoleOwner)}), + } + teams, _ := types.SetValueFrom(ctx, types.StringType, testTeamIDs) + testCases := map[string]struct { + plan *clouduserteamassignment.TFUserTeamAssignmentModel + expected *admin.AddOrRemoveUserFromTeam + }{ + "Complete model": { + plan: &clouduserteamassignment.TFUserTeamAssignmentModel{ + OrgId: types.StringValue(testOrgID), + TeamId: types.StringValue(testTeamID1), + UserId: types.StringValue(testUserID), + Username: types.StringValue(testUsername), + OrgMembershipStatus: types.StringValue(testOrgMembershipStatus), + Roles: createRolesObject(ctx, testOrgRoles, []clouduserteamassignment.TFProjectRoleAssignmentsModel{ + projectAssignment, + }), + TeamIDs: teams, + InvitationCreatedAt: types.StringValue(testInvitationCreatedAt), + InvitationExpiresAt: types.StringValue(testInvitationExpiresAt), + InviterUsername: types.StringValue(testInviterUsername), + Country: types.StringValue(testCountry), + FirstName: types.StringValue(testFirstName), + LastName: types.StringValue(testLastName), + CreatedAt: types.StringValue(testCreatedAt), + LastAuth: types.StringValue(testLastAuth), + MobileNumber: types.StringValue(testMobile), + }, + expected: &admin.AddOrRemoveUserFromTeam{ + Id: testUserID, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + req, diags := clouduserteamassignment.NewUserTeamAssignmentReq(ctx, tc.plan) + assert.False(t, diags.HasError(), "expected no diagnostics") + assert.Equal(t, tc.expected, req) + }) + } +} diff --git a/internal/service/clouduserteamassignment/resource.go b/internal/service/clouduserteamassignment/resource.go new file mode 100644 index 0000000000..97b62c0e9d --- /dev/null +++ b/internal/service/clouduserteamassignment/resource.go @@ -0,0 +1,198 @@ +package clouduserteamassignment + +import ( + "context" + "fmt" + "net/http" + "regexp" + + "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" + "go.mongodb.org/atlas-sdk/v20250312005/admin" +) + +const resourceName = "cloud_user_team_assignment" + +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 TFUserTeamAssignmentModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + connV2 := r.Client.AtlasV2 + orgID := plan.OrgId.ValueString() + teamID := plan.TeamId.ValueString() + cloudUserTeamAssignmentReq, diags := NewUserTeamAssignmentReq(ctx, &plan) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + + apiResp, _, err := connV2.MongoDBCloudUsersApi.AddUserToTeam(ctx, orgID, teamID, cloudUserTeamAssignmentReq).Execute() + if err != nil { + resp.Diagnostics.AddError(fmt.Sprintf("error assigning user to TeamID(%s):", teamID), err.Error()) + return + } + + newUserTeamAssignmentModel, diags := NewTFUserTeamAssignmentModel(ctx, apiResp) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + newUserTeamAssignmentModel.OrgId = plan.OrgId + newUserTeamAssignmentModel.TeamId = plan.TeamId + resp.Diagnostics.Append(resp.State.Set(ctx, newUserTeamAssignmentModel)...) +} + +func (r *rs) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state TFUserTeamAssignmentModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + connV2 := r.Client.AtlasV2 + orgID := state.OrgId.ValueString() + teamID := state.TeamId.ValueString() + + var userListResp *admin.PaginatedOrgUser + var httpResp *http.Response + var err error + + var userResp *admin.OrgUserResponse + if !state.UserId.IsNull() && state.UserId.ValueString() != "" { + userID := state.UserId.ValueString() + userListResp, httpResp, err = connV2.MongoDBCloudUsersApi.ListTeamUsers(ctx, orgID, teamID).Execute() + + if err != nil { + if validate.StatusNotFound(httpResp) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error getting team users by username", err.Error()) + return + } + if len(userListResp.GetResults()) == 0 { + resp.State.RemoveResource(ctx) + return + } + results := userListResp.GetResults() + for i := range results { + if results[i].GetId() == userID { + userResp = &results[i] + break + } + } + } else if !state.Username.IsNull() && state.Username.ValueString() != "" { // required for import + username := state.Username.ValueString() + params := &admin.ListTeamUsersApiParams{ + Username: &username, + OrgId: orgID, + TeamId: teamID, + } + userListResp, httpResp, err = connV2.MongoDBCloudUsersApi.ListTeamUsersWithParams(ctx, params).Execute() + + if err != nil { + if validate.StatusNotFound(httpResp) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error getting team users by username", err.Error()) + return + } + if userListResp != nil && userListResp.Results != nil { + if len(*userListResp.Results) == 0 { + resp.State.RemoveResource(ctx) + return + } + userResp = &(*userListResp.Results)[0] + } + } + + if userResp == nil { + resp.State.RemoveResource(ctx) + return + } + + newCloudUserTeamAssignmentModel, diags := NewTFUserTeamAssignmentModel(ctx, userResp) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + return + } + newCloudUserTeamAssignmentModel.OrgId = state.OrgId + newCloudUserTeamAssignmentModel.TeamId = state.TeamId + resp.Diagnostics.Append(resp.State.Set(ctx, newCloudUserTeamAssignmentModel)...) +} + +func (r *rs) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { +} + +func (r *rs) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state *TFUserTeamAssignmentModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + connV2 := r.Client.AtlasV2 + orgID := state.OrgId.ValueString() + userID := state.UserId.ValueString() + teamID := state.TeamId.ValueString() + + userInfo := &admin.AddOrRemoveUserFromTeam{ + Id: userID, + } + + _, httpResp, err := connV2.MongoDBCloudUsersApi.RemoveUserFromTeam(ctx, orgID, teamID, userInfo).Execute() + if err != nil { + if validate.StatusNotFound(httpResp) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError(fmt.Sprintf("error deleting user(%s) from TeamID(%s):", userID, teamID), 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, 3) + if !ok { + resp.Diagnostics.AddError("invalid import ID format", "expected 'org_id/team_id/user_id' or 'org_id/team_id/username', got: "+importID) + return + } + orgID, teamID, user := parts[0], parts[1], parts[2] + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("org_id"), orgID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("team_id"), teamID)...) + + 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/clouduserteamassignment/resource_migration_test.go b/internal/service/clouduserteamassignment/resource_migration_test.go new file mode 100644 index 0000000000..9911597312 --- /dev/null +++ b/internal/service/clouduserteamassignment/resource_migration_test.go @@ -0,0 +1,12 @@ +package clouduserteamassignment_test + +import ( + "testing" + + "github.com/mongodb/terraform-provider-mongodbatlas/internal/testutil/mig" +) + +func TestMigCloudUserTeamAssignmentRS_basic(t *testing.T) { + mig.SkipIfVersionBelow(t, "2.0.0") // when resource 1st released + mig.CreateAndRunTest(t, basicTestCase(t)) +} diff --git a/internal/service/clouduserteamassignment/resource_schema.go b/internal/service/clouduserteamassignment/resource_schema.go new file mode 100644 index 0000000000..46156a147b --- /dev/null +++ b/internal/service/clouduserteamassignment/resource_schema.go @@ -0,0 +1,170 @@ +package clouduserteamassignment + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func ResourceSchema(ctx context.Context) schema.Schema { + return schema.Schema{ + Attributes: map[string]schema.Attribute{ + "org_id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Unique 24-hexadecimal digit string that identifies the organization that contains your projects. Use the [/orgs](#tag/Organizations/operation/listOrganizations) endpoint to retrieve all organizations to which the authenticated user has access.", + }, + "team_id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Unique 24-hexadecimal digit string that identifies the team to which you want to assign the MongoDB Cloud user. Use the [/teams](#tag/Teams/operation/listTeams) endpoint to retrieve all teams to which the authenticated user has access.", + }, + "user_id": schema.StringAttribute{ + Required: true, + MarkdownDescription: "Unique 24-hexadecimal digit string that identifies the MongoDB Cloud user.", + }, + "username": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Email address that represents the username of 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.", + }, + "roles": schema.SingleNestedAttribute{ + Computed: true, + MarkdownDescription: "Organization and project level roles to assign the MongoDB Cloud user within one organization.", + Attributes: map[string]schema.Attribute{ + "project_role_assignments": schema.SetNestedAttribute{ + Computed: true, + MarkdownDescription: "List of project level role assignments to assign the MongoDB Cloud user.", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "project_id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Unique 24-hexadecimal digit string that identifies the project to which these roles belong.", + }, + "project_roles": schema.SetAttribute{ + ElementType: types.StringType, + Computed: true, + MarkdownDescription: "One or more project-level roles assigned to the MongoDB Cloud user.", + }, + }, + }, + }, + "org_roles": schema.SetAttribute{ + ElementType: types.StringType, + Computed: true, + MarkdownDescription: "One or more organization level roles to assign the MongoDB Cloud user.", + }, + }, + }, + "team_ids": schema.SetAttribute{ + ElementType: types.StringType, + Computed: true, + MarkdownDescription: "List of unique 24-hexadecimal digit strings that identifies the teams to which this MongoDB Cloud user belongs.", + }, + "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(), + }, + }, + "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(), + }, + }, + "first_name": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "First or given name that belongs to the MongoDB Cloud user.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "last_name": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Last name, family name, or surname that belongs to the MongoDB Cloud user.", + }, + "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(), + }, + }, + "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.", + }, + "mobile_number": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Mobile phone number that belongs to the MongoDB Cloud user.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +type TFUserTeamAssignmentModel struct { + OrgId types.String `tfsdk:"org_id"` + TeamId types.String `tfsdk:"team_id"` + UserId types.String `tfsdk:"user_id"` + Username types.String `tfsdk:"username"` + OrgMembershipStatus types.String `tfsdk:"org_membership_status"` + Roles types.Object `tfsdk:"roles"` + TeamIDs types.Set `tfsdk:"team_ids"` + InvitationCreatedAt types.String `tfsdk:"invitation_created_at"` + InvitationExpiresAt types.String `tfsdk:"invitation_expires_at"` + InviterUsername types.String `tfsdk:"inviter_username"` + Country types.String `tfsdk:"country"` + FirstName types.String `tfsdk:"first_name"` + LastName types.String `tfsdk:"last_name"` + CreatedAt types.String `tfsdk:"created_at"` + LastAuth types.String `tfsdk:"last_auth"` + MobileNumber types.String `tfsdk:"mobile_number"` +} + +type TFRolesModel struct { + ProjectRoleAssignments types.List `tfsdk:"project_role_assignments"` + OrgRoles types.Set `tfsdk:"org_roles"` +} + +type TFProjectRoleAssignmentsModel struct { + ProjectId types.String `tfsdk:"project_id"` + ProjectRoles types.Set `tfsdk:"project_roles"` +} + +var ProjectRoleAssignmentsAttrType = types.SetType{ElemType: types.ObjectType{AttrTypes: map[string]attr.Type{ + "project_id": types.StringType, + "project_roles": types.SetType{ElemType: types.StringType}, +}}} + +var RolesObjectAttrTypes = map[string]attr.Type{ + "org_roles": types.SetType{ElemType: types.StringType}, + "project_role_assignments": ProjectRoleAssignmentsAttrType, +} diff --git a/internal/service/clouduserteamassignment/resource_test.go b/internal/service/clouduserteamassignment/resource_test.go new file mode 100644 index 0000000000..062b38db27 --- /dev/null +++ b/internal/service/clouduserteamassignment/resource_test.go @@ -0,0 +1,123 @@ +package clouduserteamassignment_test + +import ( + "context" + "fmt" + "os" + "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 resourceName = "mongodbatlas_cloud_user_team_assignment.test" + +func TestAccCloudUserTeamAssignmentRS_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") + userID := os.Getenv("MONGODB_ATLAS_PROJECT_OWNER_ID") + teamName := acc.RandomName() + + return &resource.TestCase{ + PreCheck: func() { acc.PreCheckBasic(t); acc.PreCheckBasicOwnerID(t) }, + ProtoV6ProviderFactories: acc.TestAccProviderV6Factories, + CheckDestroy: checkDestroy, + Steps: []resource.TestStep{ + { + Config: configBasic(orgID, userID, teamName), + Check: checks(orgID, userID), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "user_id", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + attrs := s.RootModule().Resources[resourceName].Primary.Attributes + orgID := attrs["org_id"] + teamID := attrs["team_id"] + username := attrs["username"] + return orgID + "/" + teamID + "/" + username, nil + }, + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "user_id", + ImportStateIdFunc: func(s *terraform.State) (string, error) { + attrs := s.RootModule().Resources[resourceName].Primary.Attributes + orgID := attrs["org_id"] + teamID := attrs["team_id"] + userID := attrs["user_id"] + return orgID + "/" + teamID + "/" + userID, nil + }, + }, + }, + } +} + +func configBasic(orgID, userID, teamName string) string { + return fmt.Sprintf(` + resource "mongodbatlas_team" "test" { + org_id = %[1]q + name = %[3]q + } + resource "mongodbatlas_cloud_user_team_assignment" "test" { + org_id = %[1]q + team_id = mongodbatlas_team.test.team_id + user_id = %[2]q + } + `, + orgID, userID, teamName) +} + +func checks(orgID, userID string) resource.TestCheckFunc { + return resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(resourceName, "org_id", orgID), + resource.TestCheckResourceAttr(resourceName, "user_id", userID), + + resource.TestCheckResourceAttrSet(resourceName, "username"), + resource.TestCheckResourceAttrWith(resourceName, "username", acc.IsUsername()), + resource.TestCheckResourceAttrWith(resourceName, "created_at", acc.IsTimestamp()), + + resource.TestCheckResourceAttrWith(resourceName, "team_ids.#", acc.IntGreatThan(0)), + ) +} + +func checkDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "mongodbatlas_cloud_user_team_assignment" { + continue + } + orgID := rs.Primary.Attributes["org_id"] + teamID := rs.Primary.Attributes["team_id"] + userID := rs.Primary.Attributes["user_id"] + username := rs.Primary.Attributes["username"] + conn := acc.ConnV2() + + userListResp, _, err := conn.MongoDBCloudUsersApi.ListTeamUsers(context.Background(), orgID, teamID).Execute() + if err != nil { + continue + } + + if userListResp != nil && userListResp.Results != nil { + results := *userListResp.Results + for i := range results { + if userID != "" && results[i].GetId() == userID { + return fmt.Errorf("cloud user team assignment for user (%s) in team (%s) still exists", userID, teamID) + } + if username != "" && results[i].GetUsername() == username { + return fmt.Errorf("cloud user team assignment for user (%s) in team (%s) still exists", username, teamID) + } + } + } + } + return nil +}