Skip to content

feat: Add new singular data source mongodbatlas_cloud_user_project_assignment #3569

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Aug 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/3569.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:new-datasource
data-source/mongodbatlas_cloud_user_project_assignment
```
1 change: 1 addition & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ func (p *MongodbtlasProvider) DataSources(context.Context) []func() datasource.D
resourcepolicy.DataSource,
resourcepolicy.PluralDataSource,
clouduserorgassignment.DataSource,
clouduserprojectassignment.DataSource,
clouduserteamassignment.DataSource,
teamprojectassignment.DataSource,
apikeyprojectassignment.DataSource,
Expand Down
61 changes: 61 additions & 0 deletions internal/service/clouduserprojectassignment/data_source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package clouduserprojectassignment

import (
"context"

"github.com/hashicorp/terraform-plugin-framework/datasource"
"github.com/mongodb/terraform-provider-mongodbatlas/internal/config"
)

var _ datasource.DataSource = &cloudUserProjectAssignmentDS{}
var _ datasource.DataSourceWithConfigure = &cloudUserProjectAssignmentDS{}

func DataSource() datasource.DataSource {
return &cloudUserProjectAssignmentDS{
DSCommon: config.DSCommon{
DataSourceName: resourceName,
},
}
}

type cloudUserProjectAssignmentDS struct {
config.DSCommon
}

func (d *cloudUserProjectAssignmentDS) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) {
resp.Schema = dataSourceSchema()
}

func (d *cloudUserProjectAssignmentDS) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) {
var state TFModel
resp.Diagnostics.Append(req.Config.Get(ctx, &state)...)
if resp.Diagnostics.HasError() {
return
}

connV2 := d.Client.AtlasV2
projectID := state.ProjectId.ValueString()
userID := state.UserId.ValueString()
username := state.Username.ValueString()

if username == "" && userID == "" {
resp.Diagnostics.AddError("invalid configuration", "either username or user_id must be provided")
return
}
userResp, err := fetchProjectUser(ctx, connV2, projectID, userID, username)
if err != nil {
resp.Diagnostics.AddError(errorReadingUser, err.Error())
return
}
if userResp == nil {
resp.Diagnostics.AddError("resource not found", "no user found with the specified identifier")
return
}

newCloudUserProjectAssignmentModel, diags := NewTFModel(ctx, projectID, userResp)
if diags.HasError() {
resp.Diagnostics.Append(diags...)
return
}
resp.Diagnostics.Append(resp.State.Set(ctx, newCloudUserProjectAssignmentModel)...)
}
2 changes: 1 addition & 1 deletion internal/service/clouduserprojectassignment/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func NewTFModel(ctx context.Context, projectID string, apiResp *admin.GroupUserR
CreatedAt: types.StringPointerValue(conversion.TimePtrToStringPtr(apiResp.CreatedAt)),
FirstName: types.StringPointerValue(apiResp.FirstName),
ProjectId: types.StringValue(projectID),
UserId: types.StringValue(apiResp.GetId()),
UserId: types.StringValue(apiResp.Id),
InvitationCreatedAt: types.StringPointerValue(conversion.TimePtrToStringPtr(apiResp.InvitationCreatedAt)),
InvitationExpiresAt: types.StringPointerValue(conversion.TimePtrToStringPtr(apiResp.InvitationExpiresAt)),
InviterUsername: types.StringPointerValue(apiResp.InviterUsername),
Expand Down
11 changes: 7 additions & 4 deletions internal/service/clouduserprojectassignment/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ import (
)

const (
resourceName = "cloud_user_project_assignment"
errorReadingUser = "Error retrieving project users"
resourceName = "cloud_user_project_assignment"
errorReadingByUserID = "Error getting project users by user_id"
errorReadingByUsername = "Error getting project users by username"
invalidImportID = "Invalid import ID format"
errorReadingUser = "Error retrieving project users"
)

var _ resource.ResourceWithConfigure = &rs{}
Expand All @@ -37,7 +40,7 @@ type rs struct {
}

func (r *rs) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = resourceSchema(ctx)
resp.Schema = resourceSchema()
conversion.UpdateSchemaDescription(&resp.Schema)
}

Expand Down Expand Up @@ -232,7 +235,7 @@ func (r *rs) ImportState(ctx context.Context, req resource.ImportStateRequest, r
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)
resp.Diagnostics.AddError(invalidImportID, "expected 'project_id/user_id' or 'project_id/username', got: "+importID)
return
}
projectID, user := parts[0], parts[1]
Expand Down
73 changes: 60 additions & 13 deletions internal/service/clouduserprojectassignment/resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"regexp"
"strings"
"testing"

Expand All @@ -14,19 +15,25 @@ import (

var resourceNamePending = "mongodbatlas_cloud_user_project_assignment.test_pending"
var resourceNameActive = "mongodbatlas_cloud_user_project_assignment.test_active"
var DSNameUsername = "data.mongodbatlas_cloud_user_project_assignment.test_username"
var DSNameUserID = "data.mongodbatlas_cloud_user_project_assignment.test_user_id"

func TestAccCloudUserProjectAssignmentRS_basic(t *testing.T) {
func TestAccCloudUserProjectAssignment_basic(t *testing.T) {
resource.ParallelTest(t, *basicTestCase(t))
}

func TestAccCloudUserProjectAssignmentDS_error(t *testing.T) {
resource.ParallelTest(t, *errorTestCase(t))
}

func basicTestCase(t *testing.T) *resource.TestCase {
t.Helper()

// Use MONGODB_ATLAS_USERNAME_2 to avoid USER_ALREADY_IN_GROUP.
// The default MONGODB_ATLAS_USERNAME (Org Owner) is auto-assigned if no ProjectOwner is set.
activeUsername := os.Getenv("MONGODB_ATLAS_USERNAME_2")
pendingUsername := acc.RandomEmail()
projectName := acc.RandomName()
projectID := acc.ProjectIDExecution(t)
orgID := os.Getenv("MONGODB_ATLAS_ORG_ID")
roles := []string{"GROUP_OWNER", "GROUP_CLUSTER_MANAGER"}
updatedRoles := []string{"GROUP_OWNER", "GROUP_SEARCH_INDEX_EDITOR", "GROUP_READ_ONLY"}
Expand All @@ -37,12 +44,12 @@ func basicTestCase(t *testing.T) *resource.TestCase {
CheckDestroy: checkDestroy,
Steps: []resource.TestStep{
{
Config: configBasic(orgID, pendingUsername, activeUsername, projectName, roles),
Check: checks(pendingUsername, activeUsername, projectName, roles),
Config: configBasic(orgID, pendingUsername, activeUsername, projectID, roles),
Check: checks(pendingUsername, activeUsername, projectID, roles),
},
{
Config: configBasic(orgID, pendingUsername, activeUsername, projectName, updatedRoles),
Check: checks(pendingUsername, activeUsername, projectName, updatedRoles),
Config: configBasic(orgID, pendingUsername, activeUsername, projectID, updatedRoles),
Check: checks(pendingUsername, activeUsername, projectID, updatedRoles),
},
{
ResourceName: resourceNamePending,
Expand Down Expand Up @@ -96,7 +103,31 @@ func basicTestCase(t *testing.T) *resource.TestCase {
}
}

func configBasic(orgID, pendingUsername, activeUsername, projectName string, roles []string) string {
func errorTestCase(t *testing.T) *resource.TestCase {
t.Helper()
orgID := os.Getenv("MONGODB_ATLAS_ORG_ID")
projectID := acc.ProjectIDExecution(t)
return &resource.TestCase{
PreCheck: func() { acc.PreCheckBasic(t) },
ProtoV6ProviderFactories: acc.TestAccProviderV6Factories,
Steps: []resource.TestStep{
{
Config: configError(orgID, projectID),
ExpectError: regexp.MustCompile("either username or user_id must be provided"),
},
},
}
}

func configError(orgID, projectID string) string {
return fmt.Sprintf(`
data "mongodbatlas_cloud_user_project_assignment" "test" {
project_id = %[1]q
}
`, projectID, orgID)
}

func configBasic(orgID, pendingUsername, activeUsername, projectID string, roles []string) string {
rolesStr := `"` + strings.Join(roles, `", "`) + `"`
return fmt.Sprintf(`
resource "mongodbatlas_project" "test" {
Expand All @@ -106,26 +137,41 @@ func configBasic(orgID, pendingUsername, activeUsername, projectName string, rol

resource "mongodbatlas_cloud_user_project_assignment" "test_pending" {
username = %[3]q
project_id = mongodbatlas_project.test.id
project_id = %[1]q
roles = [%[5]s]
}

resource "mongodbatlas_cloud_user_project_assignment" "test_active" {
username = %[4]q
project_id = mongodbatlas_project.test.id
project_id = %[1]q
roles = [%[5]s]
}

data "mongodbatlas_cloud_user_project_assignment" "test_username" {
project_id = %[1]q
username = mongodbatlas_cloud_user_project_assignment.test_pending.username
}

data "mongodbatlas_cloud_user_project_assignment" "test_user_id" {
project_id = %[1]q
user_id = mongodbatlas_cloud_user_project_assignment.test_pending.user_id
}`,
projectName, orgID, pendingUsername, activeUsername, rolesStr)
projectID, orgID, pendingUsername, activeUsername, rolesStr)
}

func checks(pendingUsername, activeUsername, projectName string, roles []string) resource.TestCheckFunc {
func checks(pendingUsername, activeUsername, projectID string, roles []string) resource.TestCheckFunc {
checkFuncs := []resource.TestCheckFunc{
resource.TestCheckResourceAttr(resourceNamePending, "username", pendingUsername),
resource.TestCheckResourceAttrSet(resourceNamePending, "project_id"),
resource.TestCheckResourceAttr(resourceNamePending, "project_id", projectID),
resource.TestCheckResourceAttr(resourceNamePending, "roles.#", fmt.Sprintf("%d", len(roles))),
resource.TestCheckResourceAttr(resourceNameActive, "username", activeUsername),
resource.TestCheckResourceAttrSet(resourceNameActive, "project_id"),
resource.TestCheckResourceAttr(resourceNameActive, "project_id", projectID),
resource.TestCheckResourceAttr(resourceNameActive, "roles.#", fmt.Sprintf("%d", len(roles))),
resource.TestCheckResourceAttr(DSNameUserID, "username", pendingUsername),
resource.TestCheckResourceAttrPair(DSNameUserID, "username", resourceNamePending, "username"),
resource.TestCheckResourceAttrPair(DSNameUsername, "user_id", DSNameUserID, "user_id"),
resource.TestCheckResourceAttrPair(DSNameUsername, "project_id", DSNameUserID, "project_id"),
resource.TestCheckResourceAttrPair(DSNameUsername, "roles.#", DSNameUserID, "roles.#"),
}

for _, role := range roles {
Expand All @@ -134,6 +180,7 @@ func checks(pendingUsername, activeUsername, projectName string, roles []string)
resource.TestCheckTypeSetElemAttr(resourceNameActive, "roles.*", role),
)
}

return resource.ComposeAggregateTestCheckFunc(checkFuncs...)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
package clouduserprojectassignment

import (
"context"

"github.com/hashicorp/terraform-plugin-framework-validators/setvalidator"
dsschema "github.com/hashicorp/terraform-plugin-framework/datasource/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/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/mongodb/terraform-provider-mongodbatlas/internal/common/conversion"

"github.com/hashicorp/terraform-plugin-framework/resource/schema"
)

func resourceSchema(ctx context.Context) schema.Schema {
func resourceSchema() schema.Schema {
return schema.Schema{
Attributes: map[string]schema.Attribute{
"country": schema.StringAttribute{
Expand Down Expand Up @@ -106,6 +106,26 @@ func resourceSchema(ctx context.Context) schema.Schema {
}
}

func dataSourceSchema() dsschema.Schema {
return conversion.DataSourceSchemaFromResource(resourceSchema(), &conversion.DataSourceSchemaRequest{
RequiredFields: []string{"project_id"},
OverridenFields: dataSourceOverridenFields(),
})
}

func dataSourceOverridenFields() map[string]dsschema.Attribute {
return map[string]dsschema.Attribute{
"user_id": dsschema.StringAttribute{
Optional: true,
MarkdownDescription: "Unique 24-hexadecimal digit string that identifies the MongoDB Cloud user.",
},
"username": dsschema.StringAttribute{
Optional: 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"`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,8 @@ resources:
path: /api/atlas/v2/groups/{groupId}/users/{userId}
method: DELETE

data_sources:
cloud_user_project_assignment:
read:
path: /api/atlas/v2/groups/{groupId}/users/{userId}
method: GET