diff --git a/docs/resources/service_user_token.md b/docs/resources/service_user_token.md new file mode 100644 index 0000000..5fd92ca --- /dev/null +++ b/docs/resources/service_user_token.md @@ -0,0 +1,45 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "humanitec_service_user_token Resource - terraform-provider-humanitec" +subcategory: "" +description: |- + Tokens can be generated from service users and are used to interact with Humanitec’s API on the service user’s behalf. +--- + +# humanitec_service_user_token (Resource) + +Tokens can be generated from service users and are used to interact with Humanitec’s API on the service user’s behalf. + +## Example Usage + +```terraform +resource "humanitec_user" "service_user" { + name = "example-service-user" + role = "administrator" + type = "service" +} + +resource "humanitec_service_user_token" "token" { + id = "example-service-token" + user_id = humanitec_user.service_user.id + description = "example token description" + expires_at = "2024-04-23T21:59:59.999Z" +} +``` + + +## Schema + +### Required + +- `id` (String) Identifier of the token. Must be unique for the user. +- `user_id` (String) The service user ID. + +### Optional + +- `description` (String) A description of the token. +- `expires_at` (String) The time the token expires. If not set, the token will not expire. + +### Read-Only + +- `token` (String, Sensitive) Unique token granting access to specific services within the platform. diff --git a/examples/resources/humanitec_service_user_token/resource.tf b/examples/resources/humanitec_service_user_token/resource.tf new file mode 100644 index 0000000..17ee1d0 --- /dev/null +++ b/examples/resources/humanitec_service_user_token/resource.tf @@ -0,0 +1,12 @@ +resource "humanitec_user" "service_user" { + name = "example-service-user" + role = "administrator" + type = "service" +} + +resource "humanitec_service_user_token" "token" { + id = "example-service-token" + user_id = humanitec_user.service_user.id + description = "example token description" + expires_at = "2024-04-23T21:59:59.999Z" +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 1aed9dc..a2346f2 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -168,6 +168,7 @@ func (p *HumanitecProvider) Resources(ctx context.Context) []func() resource.Res NewResourceResourceDriver, NewResourceRule, NewResourceSecretStore, + NewResourceServiceUserToken, NewResourceValue, NewResourceUser, NewResourceWebhook, diff --git a/internal/provider/resource_service_user_token.go b/internal/provider/resource_service_user_token.go new file mode 100644 index 0000000..95eb1ab --- /dev/null +++ b/internal/provider/resource_service_user_token.go @@ -0,0 +1,210 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "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" + + "github.com/humanitec/humanitec-go-autogen" + "github.com/humanitec/humanitec-go-autogen/client" +) + +// Ensure provider defined types fully satisfy framework interfaces +var _ resource.Resource = &ResourceServiceUserToken{} + +func NewResourceServiceUserToken() resource.Resource { + return &ResourceServiceUserToken{} +} + +// ResourceServiceUserToken defines the resource implementation. +type ResourceServiceUserToken struct { + client *humanitec.Client + orgId string +} + +// ServiceUserTokenModel describes the app data model. +type ServiceUserTokenModel struct { + ID types.String `tfsdk:"id"` + UserID types.String `tfsdk:"user_id"` + Description types.String `tfsdk:"description"` + ExpiresAt types.String `tfsdk:"expires_at"` + Token types.String `tfsdk:"token"` +} + +func (r *ResourceServiceUserToken) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_service_user_token" +} + +func (r *ResourceServiceUserToken) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "Tokens can be generated from service users and are used to interact with Humanitec’s API on the service user’s behalf.", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "Identifier of the token. Must be unique for the user.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "user_id": schema.StringAttribute{ + MarkdownDescription: "The service user ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "A description of the token.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "expires_at": schema.StringAttribute{ + MarkdownDescription: "The time the token expires. If not set, the token will not expire.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "token": schema.StringAttribute{ + MarkdownDescription: "Unique token granting access to specific services within the platform.", + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *ResourceServiceUserToken) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + resdata, ok := req.ProviderData.(*HumanitecData) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = resdata.Client + r.orgId = resdata.OrgID +} + +func (r *ResourceServiceUserToken) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data *ServiceUserTokenModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + id := data.ID.ValueString() + userId := data.UserID.ValueString() + description := data.Description.ValueStringPointer() + expiresAt := data.ExpiresAt.ValueStringPointer() + + httpResp, err := r.client.CreateUserTokenWithResponse(ctx, userId, client.TokenDefinitionRequest{ + Description: description, + ExpiresAt: expiresAt, + Id: id, + Type: "static", + }) + if err != nil { + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to create service user token, got error: %s", err)) + return + } + if httpResp.StatusCode() != 200 { + resp.Diagnostics.AddError(HUM_API_ERR, fmt.Sprintf("Unable to create service user token, unexpected status code: %d, body: %s", httpResp.StatusCode(), httpResp.Body)) + return + } + + data.Token = types.StringValue(httpResp.JSON200.Token) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ResourceServiceUserToken) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data *ServiceUserTokenModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + id := data.ID.ValueString() + userId := data.UserID.ValueString() + + httpResp, err := r.client.GetUserTokenWithResponse(ctx, userId, id) + if err != nil { + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to read service user token, got error: %s", err)) + return + } + if httpResp.StatusCode() != 200 { + resp.Diagnostics.AddError(HUM_API_ERR, fmt.Sprintf("Unable to read service user token, unexpected status code: %d, body: %s", httpResp.StatusCode(), httpResp.Body)) + return + } + + parseTokenInfoResponse(httpResp.JSON200, data) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ResourceServiceUserToken) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + resp.Diagnostics.AddError("UNSUPPORTED_OPERATION", "Updating a service user token is not supported") +} + +func (r *ResourceServiceUserToken) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data *ServiceUserTokenModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + userId := data.UserID.ValueString() + id := data.ID.ValueString() + + httpResp, err := r.client.DeleteUserTokenWithResponse(ctx, userId, id) + if err != nil { + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to delete service user token, got error: %s", err)) + return + } + + if httpResp.StatusCode() != 204 { + resp.Diagnostics.AddError(HUM_API_ERR, fmt.Sprintf("Unable to delete service user token, unexpected status code: %d, body: %s", httpResp.StatusCode(), httpResp.Body)) + return + } +} + +func parseTokenInfoResponse(res *client.TokenInfoResponse, data *ServiceUserTokenModel) { + if res.Description != "" { + data.Description = types.StringPointerValue(&res.Description) + } + if res.ExpiresAt != nil && *res.ExpiresAt != "0001-01-01T00:00:00Z" { + data.ExpiresAt = types.StringPointerValue(res.ExpiresAt) + } + data.ID = types.StringValue(res.Id) +} diff --git a/internal/provider/resource_service_user_token_test.go b/internal/provider/resource_service_user_token_test.go new file mode 100644 index 0000000..755b0c0 --- /dev/null +++ b/internal/provider/resource_service_user_token_test.go @@ -0,0 +1,73 @@ +package provider + +import ( + "fmt" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccResourceServiceUserToken(t *testing.T) { + const ( + userName = "test user" + tokenId = "test-token-id" + description = "Test token description" + ) + expiresAt := time.Now().Add(24 * time.Hour).Format("2006-01-02T15:04:05.999Z") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: testAccCreateResourceServiceUserToken(userName, tokenId, "", ""), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("humanitec_service_user_token.token", "id", tokenId), + resource.TestCheckResourceAttrSet("humanitec_service_user_token.token", "token"), + resource.TestCheckNoResourceAttr("humanitec_service_user_token.token", "description"), + resource.TestCheckNoResourceAttr("humanitec_service_user_token.token", "expires_at"), + ), + }, + // Update and Read testing + { + Config: testAccCreateResourceServiceUserToken(userName, tokenId, description, expiresAt), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("humanitec_service_user_token.token", "id", tokenId), + resource.TestCheckResourceAttrSet("humanitec_service_user_token.token", "token"), + resource.TestCheckResourceAttr("humanitec_service_user_token.token", "description", description), + resource.TestCheckResourceAttr("humanitec_service_user_token.token", "expires_at", expiresAt), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +func testAccCreateResourceServiceUserToken(userName, tokenId, tokenDescription, tokenExpiration string) string { + tokenDescriptionEntry := "" + if tokenDescription != "" { + tokenDescriptionEntry = fmt.Sprintf(`description = "%s"`, tokenDescription) + } + tokenExpirationEntry := "" + if tokenExpiration != "" { + tokenExpirationEntry = fmt.Sprintf(`expires_at = "%s"`, tokenExpiration) + } + + return fmt.Sprintf(` + resource "humanitec_user" "service_user" { + name = "%s" + role = "administrator" + type = "service" + } + + resource "humanitec_service_user_token" "token" { + id = "%s" + user_id = humanitec_user.service_user.id + %s + %s + }`, + userName, tokenId, tokenDescriptionEntry, tokenExpirationEntry, + ) +} diff --git a/internal/provider/resource_user.go b/internal/provider/resource_user.go index 867e9b4..732d811 100644 --- a/internal/provider/resource_user.go +++ b/internal/provider/resource_user.go @@ -220,12 +220,12 @@ func (r *ResourceUser) Delete(ctx context.Context, req resource.DeleteRequest, r id := data.ID.ValueString() httpResp, err := r.client.DeleteUserRoleInOrgWithResponse(ctx, r.orgId, id) if err != nil { - resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to delete service user, got error: %s", err)) + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to delete user, got error: %s", err)) return } if httpResp.StatusCode() != 204 { - resp.Diagnostics.AddError(HUM_API_ERR, fmt.Sprintf("Unable to delete value, unexpected status code: %d, body: %s", httpResp.StatusCode(), httpResp.Body)) + resp.Diagnostics.AddError(HUM_API_ERR, fmt.Sprintf("Unable to delete user, unexpected status code: %d, body: %s", httpResp.StatusCode(), httpResp.Body)) return } } diff --git a/internal/provider/resource_user_test.go b/internal/provider/resource_user_test.go index bf10df0..7933de2 100644 --- a/internal/provider/resource_user_test.go +++ b/internal/provider/resource_user_test.go @@ -21,7 +21,7 @@ func TestAccResourceUser(t *testing.T) { Steps: []resource.TestStep{ // Create and Read testing { - Config: testAccResourceUser(name, role, userType), + Config: testAccCreateResourceUser(name, role, userType), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("humanitec_user.test", "name", name), resource.TestCheckResourceAttr("humanitec_user.test", "role", role), @@ -40,7 +40,7 @@ func TestAccResourceUser(t *testing.T) { }, // Update and Read testing { - Config: testAccResourceUser(name, newRole, userType), + Config: testAccCreateResourceUser(name, newRole, userType), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("humanitec_user.test", "name", name), resource.TestCheckResourceAttr("humanitec_user.test", "role", newRole), @@ -52,7 +52,7 @@ func TestAccResourceUser(t *testing.T) { }) } -func testAccResourceUser(name, role, userType string) string { +func testAccCreateResourceUser(name, role, userType string) string { return fmt.Sprintf(` resource "humanitec_user" "test" { name = "%s"