Skip to content

Commit 87b6606

Browse files
jaymclaude
andauthored
Add mondoo_team_member resource (#401)
* Add mondoo_team_member resource for managing team membership Adds a new Terraform resource to add/remove users from teams by email. Uses the addTeamMember/removeTeamMember mutations and the teamMember query for drift detection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Rename email field to identity for team member resource Use the unified identity field that accepts either email or MRN, matching the server-side GraphQL API change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix team member test assertion for pending users Remove member_mrn check since pending users (no Mondoo account) have an empty member_mrn, which fails TestCheckResourceAttrSet. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add UseStateForUnknown plan modifier to member_mrn Prevents unnecessary (known after apply) diff noise on every plan when member_mrn hasn't changed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Update copyright headers for team member resource files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add ImportState support for team member resource Import using composite key format: <team_mrn>:<identity> Example: terraform import mondoo_team_member.alice "//captain.api.mondoo.app/teams/abc:alice@example.com" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * update copyright * Fix ImportState test to use team_mrn as identifier attribute The resource has no single id/mrn attribute, so use ImportStateVerifyIdentifierAttribute to specify team_mrn. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 37ad885 commit 87b6606

6 files changed

Lines changed: 385 additions & 0 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
terraform {
2+
required_providers {
3+
mondoo = {
4+
source = "mondoohq/mondoo"
5+
version = ">= 0.35.7"
6+
}
7+
}
8+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
variable "org_id" {
2+
description = "The ID of the organization in which to create the space and teams"
3+
type = string
4+
}
5+
6+
provider "mondoo" {}
7+
8+
data "mondoo_organization" "example" {
9+
id = var.org_id
10+
}
11+
12+
# Add a member to a team by email
13+
resource "mondoo_team" "example" {
14+
name = "security-team"
15+
scope_mrn = data.mondoo_organization.example.mrn
16+
}
17+
18+
resource "mondoo_team_member" "alice" {
19+
team_mrn = mondoo_team.example.mrn
20+
identity = "alice@example.com"
21+
}
22+
23+
resource "mondoo_team_member" "bob" {
24+
team_mrn = mondoo_team.example.mrn
25+
identity = "bob@example.com"
26+
}
27+

internal/provider/gql.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1374,6 +1374,77 @@ func (c *ExtendedGqlClient) RemoveTeamExternalGroupMapping(ctx context.Context,
13741374
return c.Mutate(ctx, &mutation, mondoov1.String(mrn), nil)
13751375
}
13761376

1377+
// Team Member types and methods
1378+
1379+
type AddTeamMemberInput struct {
1380+
TeamMrn mondoov1.String `json:"teamMrn"`
1381+
Identity *mondoov1.String `json:"identity,omitempty"`
1382+
}
1383+
1384+
type AddTeamMemberPayload struct {
1385+
CreatedPending mondoov1.Boolean `json:"createdPending"`
1386+
MemberMrn *mondoov1.String `json:"memberMrn"`
1387+
}
1388+
1389+
type RemoveTeamMemberInput struct {
1390+
TeamMrn mondoov1.String `json:"teamMrn"`
1391+
Identity *mondoov1.String `json:"identity,omitempty"`
1392+
}
1393+
1394+
type TeamMemberPayload struct {
1395+
Mrn *mondoov1.String `json:"mrn"`
1396+
Email mondoov1.String `json:"email"`
1397+
Name *mondoov1.String `json:"name"`
1398+
}
1399+
1400+
func (c *ExtendedGqlClient) AddTeamMember(ctx context.Context, input AddTeamMemberInput) (AddTeamMemberPayload, error) {
1401+
var mutation struct {
1402+
AddTeamMember AddTeamMemberPayload `graphql:"addTeamMember(input: $input)"`
1403+
}
1404+
1405+
tflog.Trace(ctx, "AddTeamMemberInput", map[string]interface{}{
1406+
"input": fmt.Sprintf("%+v", input),
1407+
})
1408+
1409+
err := c.Mutate(ctx, &mutation, input, nil)
1410+
return mutation.AddTeamMember, err
1411+
}
1412+
1413+
func (c *ExtendedGqlClient) GetTeamMember(ctx context.Context, teamMrn string, identity string) (*TeamMemberPayload, error) {
1414+
var query struct {
1415+
TeamMember *TeamMemberPayload `graphql:"teamMember(teamMrn: $teamMrn, identity: $identity)"`
1416+
}
1417+
variables := map[string]interface{}{
1418+
"teamMrn": mondoov1.String(teamMrn),
1419+
"identity": mondoov1.String(identity),
1420+
}
1421+
1422+
tflog.Trace(ctx, "GetTeamMember", map[string]interface{}{
1423+
"teamMrn": teamMrn,
1424+
"identity": identity,
1425+
})
1426+
1427+
err := c.Query(ctx, &query, variables)
1428+
if err != nil {
1429+
return nil, err
1430+
}
1431+
return query.TeamMember, nil
1432+
}
1433+
1434+
func (c *ExtendedGqlClient) RemoveTeamMember(ctx context.Context, input RemoveTeamMemberInput) error {
1435+
var mutation struct {
1436+
RemoveTeamMember struct {
1437+
MemberMrn *mondoov1.String `json:"memberMrn"`
1438+
} `graphql:"removeTeamMember(input: $input)"`
1439+
}
1440+
1441+
tflog.Trace(ctx, "RemoveTeamMemberInput", map[string]interface{}{
1442+
"input": fmt.Sprintf("%+v", input),
1443+
})
1444+
1445+
return c.Mutate(ctx, &mutation, input, nil)
1446+
}
1447+
13771448
type RoleInput struct {
13781449
Mrn mondoov1.String `json:"mrn"`
13791450
}

internal/provider/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ func (p *MondooProvider) Resources(_ context.Context) []func() resource.Resource
223223
NewTeamResource,
224224
NewTeamExternalGroupMappingResource,
225225
NewResourceContactsResource,
226+
NewTeamMemberResource,
226227
NewIAMBindingResource,
227228
NewExportGSCBucketResource,
228229
NewExportS3BucketResource,
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Copyright Mondoo, Inc. 2024, 2026
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package provider
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"strings"
10+
11+
"github.com/hashicorp/terraform-plugin-framework/path"
12+
"github.com/hashicorp/terraform-plugin-framework/resource"
13+
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
14+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
15+
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
16+
"github.com/hashicorp/terraform-plugin-framework/types"
17+
mondoov1 "go.mondoo.com/mondoo-go"
18+
)
19+
20+
// Ensure provider defined types fully satisfy framework interfaces.
21+
var _ resource.Resource = &TeamMemberResource{}
22+
var _ resource.ResourceWithImportState = &TeamMemberResource{}
23+
24+
func NewTeamMemberResource() resource.Resource {
25+
return &TeamMemberResource{}
26+
}
27+
28+
// TeamMemberResource defines the resource implementation.
29+
type TeamMemberResource struct {
30+
client *ExtendedGqlClient
31+
}
32+
33+
// TeamMemberResourceModel describes the resource data model.
34+
type TeamMemberResourceModel struct {
35+
TeamMrn types.String `tfsdk:"team_mrn"`
36+
Identity types.String `tfsdk:"identity"`
37+
MemberMrn types.String `tfsdk:"member_mrn"`
38+
}
39+
40+
func (r *TeamMemberResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
41+
resp.TypeName = req.ProviderTypeName + "_team_member"
42+
}
43+
44+
func (r *TeamMemberResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
45+
resp.Schema = schema.Schema{
46+
MarkdownDescription: `This resource manages team membership in Mondoo. It allows adding users to teams by email address or MRN. If the user does not yet have a Mondoo account, a pending membership is created.
47+
48+
**Example usage:**
49+
50+
` + "```hcl" + `
51+
resource "mondoo_team" "example" {
52+
name = "security-team"
53+
scope_mrn = mondoo_organization.example.mrn
54+
}
55+
56+
resource "mondoo_team_member" "alice" {
57+
team_mrn = mondoo_team.example.mrn
58+
identity = "alice@example.com"
59+
}
60+
` + "```",
61+
62+
Attributes: map[string]schema.Attribute{
63+
"team_mrn": schema.StringAttribute{
64+
MarkdownDescription: "MRN of the team to add the member to.",
65+
Required: true,
66+
PlanModifiers: []planmodifier.String{
67+
stringplanmodifier.RequiresReplace(),
68+
},
69+
},
70+
"identity": schema.StringAttribute{
71+
MarkdownDescription: "Email address or MRN of the user to add to the team.",
72+
Required: true,
73+
PlanModifiers: []planmodifier.String{
74+
stringplanmodifier.RequiresReplace(),
75+
},
76+
},
77+
"member_mrn": schema.StringAttribute{
78+
MarkdownDescription: "MRN of the member. Empty if the user has not yet registered.",
79+
Computed: true,
80+
PlanModifiers: []planmodifier.String{
81+
stringplanmodifier.UseStateForUnknown(),
82+
},
83+
},
84+
},
85+
}
86+
}
87+
88+
func (r *TeamMemberResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
89+
// Prevent panic if the provider has not been configured.
90+
if req.ProviderData == nil {
91+
return
92+
}
93+
94+
client, ok := req.ProviderData.(*ExtendedGqlClient)
95+
96+
if !ok {
97+
resp.Diagnostics.AddError(
98+
"Unexpected Resource Configure Type",
99+
fmt.Sprintf("Expected *ExtendedGqlClient, got: %T. Please report this issue to the provider developers.", req.ProviderData),
100+
)
101+
102+
return
103+
}
104+
105+
r.client = client
106+
}
107+
108+
func (r *TeamMemberResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
109+
var data TeamMemberResourceModel
110+
111+
// Read Terraform plan data into the model
112+
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
113+
114+
if resp.Diagnostics.HasError() {
115+
return
116+
}
117+
118+
identity := mondoov1.String(data.Identity.ValueString())
119+
input := AddTeamMemberInput{
120+
TeamMrn: mondoov1.String(data.TeamMrn.ValueString()),
121+
Identity: &identity,
122+
}
123+
124+
payload, err := r.client.AddTeamMember(ctx, input)
125+
if err != nil {
126+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to add team member, got error: %s", err))
127+
return
128+
}
129+
130+
// Map response back to schema
131+
if payload.MemberMrn != nil {
132+
data.MemberMrn = types.StringValue(string(*payload.MemberMrn))
133+
} else {
134+
data.MemberMrn = types.StringValue("")
135+
}
136+
137+
// Save data into Terraform state
138+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
139+
}
140+
141+
func (r *TeamMemberResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
142+
var data TeamMemberResourceModel
143+
144+
// Read Terraform prior state data into the model
145+
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
146+
147+
if resp.Diagnostics.HasError() {
148+
return
149+
}
150+
151+
// Get the team member from the API
152+
member, err := r.client.GetTeamMember(ctx, data.TeamMrn.ValueString(), data.Identity.ValueString())
153+
if err != nil {
154+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to read team member, got error: %s", err))
155+
return
156+
}
157+
158+
// If the member is not found, remove from state (drift detection)
159+
if member == nil {
160+
resp.State.RemoveResource(ctx)
161+
return
162+
}
163+
164+
// Map response back to schema
165+
if member.Mrn != nil {
166+
data.MemberMrn = types.StringValue(string(*member.Mrn))
167+
} else {
168+
data.MemberMrn = types.StringValue("")
169+
}
170+
171+
// Save updated data into Terraform state
172+
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
173+
}
174+
175+
func (r *TeamMemberResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
176+
// Team members are immutable - both team_mrn and identity require replacement
177+
// This method should never be called due to RequiresReplace plan modifiers
178+
resp.Diagnostics.AddError(
179+
"Unexpected Update Call",
180+
"Team members cannot be updated. Both team_mrn and identity changes require replacement.",
181+
)
182+
}
183+
184+
func (r *TeamMemberResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
185+
var data TeamMemberResourceModel
186+
187+
// Read Terraform prior state data into the model
188+
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
189+
190+
if resp.Diagnostics.HasError() {
191+
return
192+
}
193+
194+
identity := mondoov1.String(data.Identity.ValueString())
195+
input := RemoveTeamMemberInput{
196+
TeamMrn: mondoov1.String(data.TeamMrn.ValueString()),
197+
Identity: &identity,
198+
}
199+
200+
err := r.client.RemoveTeamMember(ctx, input)
201+
if err != nil {
202+
resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to remove team member, got error: %s", err))
203+
return
204+
}
205+
}
206+
207+
func (r *TeamMemberResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
208+
// Import ID format: <team_mrn>:<identity>
209+
parts := strings.SplitN(req.ID, ":", 2)
210+
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
211+
resp.Diagnostics.AddError(
212+
"Invalid Import ID",
213+
fmt.Sprintf("Expected import ID format: <team_mrn>:<identity>, got: %s", req.ID),
214+
)
215+
return
216+
}
217+
218+
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("team_mrn"), parts[0])...)
219+
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("identity"), parts[1])...)
220+
}

0 commit comments

Comments
 (0)