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 18 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.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)...)
}
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,18 +15,24 @@ 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.testUsername"
var DSNameUserID = "data.mongodbatlas_cloud_user_project_assignment.testUserID"

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()

orgID := os.Getenv("MONGODB_ATLAS_ORG_ID")
activeUsername := os.Getenv("MONGODB_ATLAS_USERNAME_2")
pendingUsername := acc.RandomEmail()
projectName := acc.RandomName()
projectID := acc.ProjectIDExecution(t)
roles := []string{"GROUP_OWNER", "GROUP_CLUSTER_MANAGER"}
updatedRoles := []string{"GROUP_OWNER", "GROUP_SEARCH_INDEX_EDITOR", "GROUP_READ_ONLY"}

Expand All @@ -35,12 +42,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 @@ -94,7 +101,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 @@ -104,26 +135,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" "testUsername" {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: stay consistent with format used for naming resources/data sources, for example, for resource you used test_active (with an _) but here uou name it testUsername (different casing).
Use one format consistently & this applies to all tests, examples, documentation etc

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, changed it to be consistent

project_id = %[1]q
username = mongodbatlas_cloud_user_project_assignment.test_pending.username
}

data "mongodbatlas_cloud_user_project_assignment" "testUserID" {
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 @@ -132,6 +178,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