Skip to content

Support multiple team tokens for a single team with tfe_team_token #1698

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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
BUG FIXES:
* `d/tfe_outputs`: fixes 'error inferring type for key' for output objects that had a key with null value. (#1709), by @uturunku1 [#1699](https://github.com/hashicorp/terraform-provider-tfe/pull/1709)

FEATURES:

* `r/tfe_team_token`: Adds support for creating multiple team tokens for a single team by adding the `description` attribute, which must be unique per team, by @mkam [#1698](https://github.com/hashicorp/terraform-provider-tfe/pull/1698)

## v0.65.2

BUG FIXES:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-slug v0.16.5
github.com/hashicorp/go-tfe v1.78.0
github.com/hashicorp/go-tfe v1.78.1-0.20250418170002-da71abb96c5a
Copy link
Collaborator

Choose a reason for hiding this comment

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

I assume this is just to get nil Description support?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, that's correct!

github.com/hashicorp/go-version v1.7.0
github.com/hashicorp/hcl v1.0.0
github.com/hashicorp/hcl/v2 v2.23.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISH
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hashicorp/go-slug v0.16.5 h1:L3frr/NptQ9/HLlIWoTmv4tx3Lh+Gx0o5XINGet3cN0=
github.com/hashicorp/go-slug v0.16.5/go.mod h1:X5fm++dL59cDOX8j48CqHr4KARTQau7isGh0ZVxJB5I=
github.com/hashicorp/go-tfe v1.78.0 h1:RMkrEO3N4hbnXqoMWl44TnSCkMXpON5iEOOJf+UxWAo=
github.com/hashicorp/go-tfe v1.78.0/go.mod h1:6dUFMBKh0jkxlRsrw7bYD2mby0efdwE4dtlAuTogIzA=
github.com/hashicorp/go-tfe v1.78.1-0.20250418170002-da71abb96c5a h1:NOAwqEnCuXNqMNdeFRV4NxEutcmxGE1/3ML0AbXQ15I=
github.com/hashicorp/go-tfe v1.78.1-0.20250418170002-da71abb96c5a/go.mod h1:6dUFMBKh0jkxlRsrw7bYD2mby0efdwE4dtlAuTogIzA=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
Expand Down
126 changes: 103 additions & 23 deletions internal/provider/resource_tfe_team_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,21 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"

tfe "github.com/hashicorp/go-tfe"
"github.com/hashicorp/terraform-plugin-framework-validators/boolvalidator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/path"
"github.com/hashicorp/terraform-plugin-framework/resource"
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier"
"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/types/basetypes"
"github.com/hashicorp/terraform-plugin-log/tflog"
)

Expand All @@ -39,6 +44,7 @@ type modelTFETeamToken struct {
ForceRegenerate types.Bool `tfsdk:"force_regenerate"`
Token types.String `tfsdk:"token"`
ExpiredAt types.String `tfsdk:"expired_at"`
Description types.String `tfsdk:"description"`
}

func (r *resourceTFETeamToken) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
Expand Down Expand Up @@ -83,6 +89,9 @@ func (r *resourceTFETeamToken) Schema(_ context.Context, _ resource.SchemaReques
PlanModifiers: []planmodifier.Bool{
boolplanmodifier.RequiresReplace(),
},
Validators: []validator.Bool{
boolvalidator.ConflictsWith(path.MatchRoot("description")),
},
},
"token": schema.StringAttribute{
Description: "The generated token.",
Expand All @@ -96,8 +105,18 @@ func (r *resourceTFETeamToken) Schema(_ context.Context, _ resource.SchemaReques
stringplanmodifier.RequiresReplace(),
},
},
"description": schema.StringAttribute{
Description: "The description of the token, which must be unique per team.",
Optional: true,
PlanModifiers: []planmodifier.String{
stringplanmodifier.RequiresReplace(),
},
Validators: []validator.String{
stringvalidator.ConflictsWith(path.MatchRoot("force_regenerate")),
},
},
},
Description: "Generates a new team token and overrides existing token if one exists.",
Description: "Generates a new team token. If no description is provided, it follows the legacy behavior to override the existing, descriptionless token if one exists.",
}
}

Expand All @@ -110,28 +129,33 @@ func (r *resourceTFETeamToken) Create(ctx context.Context, req resource.CreateRe
}

teamID := plan.TeamID.ValueString()
tflog.Debug(ctx, fmt.Sprintf("Check if a token already exists for team: %s", teamID))
_, err := r.config.Client.TeamTokens.Read(ctx, teamID)
if err != nil && !errors.Is(err, tfe.ErrResourceNotFound) {
resp.Diagnostics.AddError(
fmt.Sprintf("Error checking if a token exists for team %s", teamID),
err.Error(),
)
return
}
if err == nil {
if !plan.ForceRegenerate.ValueBool() {
if plan.Description.IsNull() {
// No description indicates legacy behavior where token will be regenerated if it does not exist
tflog.Debug(ctx, fmt.Sprintf("Check if a token already exists for team: %s", teamID))
_, err := r.config.Client.TeamTokens.Read(ctx, teamID)
if err != nil && !errors.Is(err, tfe.ErrResourceNotFound) {
resp.Diagnostics.AddError(
fmt.Sprintf("A token already exists for team: %s", teamID),
"Set force_regenerate to true to regenerate the token.",
fmt.Sprintf("Error checking if a token exists for team %s", teamID),
err.Error(),
)
return
}
tflog.Debug(ctx, fmt.Sprintf("Regenerating existing token for team: %s", teamID))
if err == nil {
if !plan.ForceRegenerate.ValueBool() {
resp.Diagnostics.AddError(
fmt.Sprintf("A token already exists for team: %s", teamID),
"Set force_regenerate to true to regenerate the token.",
)
return
}
tflog.Debug(ctx, fmt.Sprintf("Regenerating existing token for team: %s", teamID))
}
}

expiredAt := plan.ExpiredAt.ValueString()
options := tfe.TeamTokenCreateOptions{}
options := tfe.TeamTokenCreateOptions{
Description: plan.Description.ValueStringPointer(),
}
if !plan.ExpiredAt.IsNull() && expiredAt != "" {
expiry, err := time.Parse(time.RFC3339, expiredAt)
if err != nil {
Expand All @@ -153,22 +177,29 @@ func (r *resourceTFETeamToken) Create(ctx context.Context, req resource.CreateRe
return
}

result := modelFromTFEToken(plan.TeamID, types.StringValue(token.Token), plan.ForceRegenerate, plan.ExpiredAt)
result := modelFromTFEToken(plan.TeamID, types.StringValue(token.ID), types.StringValue(token.Token), plan.ForceRegenerate, plan.ExpiredAt, plan.Description)
resp.Diagnostics.Append(resp.State.Set(ctx, result)...)
}

func modelFromTFEToken(teamID types.String, stateValue types.String, forceRegenerate types.Bool, expiredAt types.String) modelTFETeamToken {
func modelFromTFEToken(teamID types.String, tokenID types.String, stateValue types.String, forceRegenerate types.Bool, expiredAt types.String, description types.String) modelTFETeamToken {
m := modelTFETeamToken{
ID: teamID,
TeamID: teamID,
ForceRegenerate: forceRegenerate,
ExpiredAt: types.StringNull(),
Token: stateValue,
Description: types.StringNull(),
}
if !expiredAt.IsNull() {
m.ExpiredAt = expiredAt
}

if !description.IsNull() {
m.Description = description
m.ID = tokenID
} else {
m.ID = teamID
}

return m
}

Expand All @@ -182,7 +213,12 @@ func (r *resourceTFETeamToken) Read(ctx context.Context, req resource.ReadReques

teamID := state.TeamID.ValueString()
tflog.Debug(ctx, fmt.Sprintf("Read the token from team: %s", teamID))
_, err := r.config.Client.TeamTokens.Read(ctx, teamID)
var err error
if isTokenID(state.ID.ValueString()) {
_, err = r.config.Client.TeamTokens.ReadByID(ctx, state.ID.ValueString())
} else {
_, err = r.config.Client.TeamTokens.Read(ctx, teamID)
}
if err != nil {
if errors.Is(err, tfe.ErrResourceNotFound) {
tflog.Debug(ctx, fmt.Sprintf("Token for team %s no longer exists", teamID))
Expand All @@ -195,7 +231,7 @@ func (r *resourceTFETeamToken) Read(ctx context.Context, req resource.ReadReques
)
return
}
result := modelFromTFEToken(state.TeamID, state.Token, state.ForceRegenerate, state.ExpiredAt)
result := modelFromTFEToken(state.TeamID, state.ID, state.Token, state.ForceRegenerate, state.ExpiredAt, state.Description)
resp.Diagnostics.Append(resp.State.Set(ctx, result)...)
}

Expand All @@ -214,7 +250,13 @@ func (r *resourceTFETeamToken) Delete(ctx context.Context, req resource.DeleteRe

teamID := state.TeamID.ValueString()
tflog.Debug(ctx, fmt.Sprintf("Delete the token from team: %s", teamID))
if err := r.config.Client.TeamTokens.Delete(ctx, teamID); err != nil {
var err error
if isTokenID(state.ID.ValueString()) {
err = r.config.Client.TeamTokens.DeleteByID(ctx, state.ID.ValueString())
} else {
err = r.config.Client.TeamTokens.Delete(ctx, teamID)
}
if err != nil {
if errors.Is(err, tfe.ErrResourceNotFound) {
tflog.Debug(ctx, fmt.Sprintf("Token for team %s no longer exists", teamID))
return
Expand All @@ -227,5 +269,43 @@ func (r *resourceTFETeamToken) Delete(ctx context.Context, req resource.DeleteRe
}

func (r *resourceTFETeamToken) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("team_id"), req, resp)
if !isTokenID(req.ID) {
// Set the team ID field
resource.ImportStatePassthroughID(ctx, path.Root("team_id"), req, resp)
return
}

// Fetch token by ID to set attributes
token, err := r.config.Client.TeamTokens.ReadByID(ctx, req.ID)
if err != nil {
resp.Diagnostics.AddError("Error importing team token", err.Error())
return
}
if token.Team == nil {
resp.Diagnostics.AddError("Error importing team token", "token did not return associated team")
return
}

var expiredAt types.String
if !token.ExpiredAt.IsZero() {
expiredAt = types.StringValue(token.ExpiredAt.Format(time.RFC3339))
} else {
expiredAt = types.StringNull()
}

var description types.String
if token.Description != nil {
description = types.StringValue(*token.Description)
} else {
description = types.StringNull()
}

result := modelFromTFEToken(types.StringValue(token.Team.ID), types.StringValue(token.ID), types.StringValue(token.Token), basetypes.NewBoolNull(), expiredAt, description)
resp.Diagnostics.Append(resp.State.Set(ctx, &result)...)
}

// Determines whether the ID of the resource is the ID of the authentication token
// or the ID of the team the token belongs to.
func isTokenID(id string) bool {
return strings.HasPrefix(id, "at-")
}
Loading