From 89689cce435c444126e6a6832dd32d109bbb6dbd Mon Sep 17 00:00:00 2001 From: Aleksander Zaruczewski Date: Fri, 12 Jan 2024 11:02:15 +0200 Subject: [PATCH] feat(organization): application user tokens support (#1522) --- CHANGELOG.md | 1 + .../organization_application_user_token.md | 53 +++ .../organization_user_group_member.md | 1 + internal/plugin/errmsg/errmsg.go | 10 + internal/plugin/provider.go | 1 + .../organization_application_user.go | 15 +- ...ganization_application_user_data_source.go | 12 +- .../organization_application_user_test.go | 45 +- .../organization_application_user_token.go | 432 ++++++++++++++++++ .../organization_user_group_member.go | 44 +- .../organization_user_group_member_test.go | 9 +- internal/plugin/util/diag.go | 13 + internal/plugin/util/helpers.go | 29 +- internal/plugin/util/pluginhelpers.go | 67 +++ internal/schemautil/plugin.go | 154 ------- 15 files changed, 678 insertions(+), 208 deletions(-) create mode 100644 docs/resources/organization_application_user_token.md create mode 100644 internal/plugin/service/organization/organization_application_user_token.go create mode 100644 internal/plugin/util/pluginhelpers.go delete mode 100644 internal/schemautil/plugin.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a52df29..32f052199 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ nav_order: 1 ## [MAJOR.MINOR.PATCH] - YYYY-MM-DD - Add organization application users support +- Add organization application user tokens support - Configure "insufficient broker" error retries timeout - Enable `local_retention_*` fields in `aiven_kafka_topic` resource - Validate that `local_retention_bytes` is not bigger than `retention_bytes` diff --git a/docs/resources/organization_application_user_token.md b/docs/resources/organization_application_user_token.md new file mode 100644 index 000000000..25efa1411 --- /dev/null +++ b/docs/resources/organization_application_user_token.md @@ -0,0 +1,53 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "aiven_organization_application_user_token Resource - terraform-provider-aiven" +subcategory: "" +description: |- + Creates and manages an organization application user token in Aiven. Please note that this resource is in beta and may change without notice. To use this resource, please set the PROVIDERAIVENENABLE_BETA environment variable. +--- + +# aiven_organization_application_user_token (Resource) + +Creates and manages an organization application user token in Aiven. Please note that this resource is in beta and may change without notice. To use this resource, please set the PROVIDER_AIVEN_ENABLE_BETA environment variable. + + + + +## Schema + +### Required + +- `organization_id` (String) Identifier of the organization the application user token belongs to. +- `user_id` (String) Identifier of the application user the token belongs to. + +### Optional + +- `description` (String) Description of the token. +- `extend_when_used` (Boolean) True to extend token expiration time when token is used. Only applicable if max_age_seconds is specified. +- `max_age_seconds` (Number) Time the token remains valid since creation (or since last use if extend_when_used is true). +- `scopes` (Set of String) Scopes this token is restricted to if specified. +- `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts)) + +### Read-Only + +- `create_time` (String) Time when the token was created. +- `created_manually` (Boolean) True for tokens explicitly created via the access_tokens API, false for tokens created via login. +- `currently_active` (Boolean) True if API request was made with this access token. +- `expiry_time` (String) Timestamp when the access token will expire unless extended, if ever. +- `full_token` (String, Sensitive) Full token. +- `id` (String) Compound identifier of the organization application user token. +- `last_ip` (String) IP address of the last request made with this token. +- `last_used_time` (String) Timestamp when the access token was last used, if ever. +- `last_user_agent` (String) User agent of the last request made with this token. +- `last_user_agent_human_readable` (String) User agent of the last request made with this token in human-readable format. +- `token_prefix` (String) Prefix of the token. + + +### Nested Schema for `timeouts` + +Optional: + +- `create` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). +- `delete` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Setting a timeout for a Delete operation is only applicable if changes are saved into state before the destroy operation occurs. +- `read` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). Read operations occur during any refresh or planning operation when refresh is enabled. +- `update` (String) A string that can be [parsed as a duration](https://pkg.go.dev/time#ParseDuration) consisting of numbers and unit suffixes, such as "30s" or "2h45m". Valid time units are "s" (seconds), "m" (minutes), "h" (hours). diff --git a/docs/resources/organization_user_group_member.md b/docs/resources/organization_user_group_member.md index b044b9b59..8f4a6f322 100644 --- a/docs/resources/organization_user_group_member.md +++ b/docs/resources/organization_user_group_member.md @@ -27,6 +27,7 @@ Adds and manages users in a user group. Please note that this resource is in bet ### Read-Only +- `id` (String) Compound identifier of the organization user group member. - `last_activity_time` (String) Last activity time of the user group member. diff --git a/internal/plugin/errmsg/errmsg.go b/internal/plugin/errmsg/errmsg.go index 3f77cbc33..fe38f8634 100644 --- a/internal/plugin/errmsg/errmsg.go +++ b/internal/plugin/errmsg/errmsg.go @@ -43,6 +43,9 @@ const ( // SummaryErrorDeletingResource is the error summary for when a resource cannot be deleted. SummaryErrorDeletingResource = "Error Deleting Resource" + // SummaryErrorImportingResource is the error summary for when a resource cannot be imported. + SummaryErrorImportingResource = "Error Importing Resource" + // SummaryDuplicateFoundByName is the error summary for when a duplicate resource is found by name. SummaryDuplicateFoundByName = "Duplicate Found By Name" @@ -86,6 +89,10 @@ var ( // DetailErrorDeletingResource is the detailed error message for when a resource cannot be deleted. DetailErrorDeletingResource = "An unexpected error occurred while deleting the resource (%s): %s." + // DetailErrorImportingResourceNotSupported is the detailed error message for when a resource cannot be imported + // because it is not supported. + DetailErrorImportingResourceNotSupported = "Importing the resource (%s) is not supported." + // DetailDuplicateFoundByName is the detailed error message for when a duplicate resource is found by name. DetailDuplicateFoundByName = "Multiple resources with the same name (%s) were found. Please use the ID to " + "uniquely identify the resource." @@ -108,4 +115,7 @@ var ( // AivenResourceNotFound is the error message for when an Aiven resource cannot be found. AivenResourceNotFound = "aiven resource %s with compound ID %s not found" + + // UnableToSetValueFrom is the error message for when a Set cannot be created from a value. + UnableToSetValueFrom = "unable to set value from %v" ) diff --git a/internal/plugin/provider.go b/internal/plugin/provider.go index e66c4953e..2fcf94ff0 100644 --- a/internal/plugin/provider.go +++ b/internal/plugin/provider.go @@ -120,6 +120,7 @@ func (p *AivenProvider) Resources(context.Context) []func() resource.Resource { organization.NewOrganizationUserGroupMembersResource, organization.NewOrganizationGroupProjectResource, organization.NewOrganizationApplicationUser, + organization.NewOrganizationApplicationUserToken, } resources = append(resources, betaResources...) diff --git a/internal/plugin/service/organization/organization_application_user.go b/internal/plugin/service/organization/organization_application_user.go index b6c1d0ec7..bf08263a7 100644 --- a/internal/plugin/service/organization/organization_application_user.go +++ b/internal/plugin/service/organization/organization_application_user.go @@ -43,10 +43,10 @@ type organizationApplicationUser struct { type organizationApplicationUserModel struct { // ID is the identifier of the organization application user. ID types.String `tfsdk:"id"` - // UserID is the identifier of the organization application user. - UserID types.String `tfsdk:"user_id"` // OrganizationID is the identifier of the organization the application user belongs to. OrganizationID types.String `tfsdk:"organization_id"` + // UserID is the identifier of the organization application user. + UserID types.String `tfsdk:"user_id"` // Name is the name of the organization application user. Name types.String `tfsdk:"name"` // Email is the email of the organization application user. @@ -87,6 +87,13 @@ func (r *organizationApplicationUser) Schema( stringplanmodifier.UseStateForUnknown(), }, }, + "organization_id": schema.StringAttribute{ + Description: "Identifier of the organization the application user belongs to.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, "user_id": schema.StringAttribute{ Description: "Identifier of the organization application user.", Computed: true, @@ -94,10 +101,6 @@ func (r *organizationApplicationUser) Schema( stringplanmodifier.UseStateForUnknown(), }, }, - "organization_id": schema.StringAttribute{ - Description: "Identifier of the organization the application user belongs to.", - Required: true, - }, "name": schema.StringAttribute{ Description: "Name of the organization application user.", Required: true, diff --git a/internal/plugin/service/organization/organization_application_user_data_source.go b/internal/plugin/service/organization/organization_application_user_data_source.go index 387649ddc..111ffd36b 100644 --- a/internal/plugin/service/organization/organization_application_user_data_source.go +++ b/internal/plugin/service/organization/organization_application_user_data_source.go @@ -34,10 +34,10 @@ type organizationApplicationUserDataSource struct { // organizationApplicationUserDataSourceModel is the model for the organization application user data source. type organizationApplicationUserDataSourceModel struct { - // UserID is the identifier of the organization application user. - UserID types.String `tfsdk:"user_id"` // OrganizationID is the identifier of the organization the application user belongs to. OrganizationID types.String `tfsdk:"organization_id"` + // UserID is the identifier of the organization application user. + UserID types.String `tfsdk:"user_id"` // Name is the name of the organization application user. Name types.String `tfsdk:"name"` // Email is the email of the organization application user. @@ -69,14 +69,14 @@ func (r *organizationApplicationUserDataSource) Schema( resp.Schema = schema.Schema{ Description: "Retrieves information about an organization application user from Aiven.", Attributes: map[string]schema.Attribute{ - "user_id": schema.StringAttribute{ - Description: "Identifier of the organization application user.", - Required: true, - }, "organization_id": schema.StringAttribute{ Description: "Identifier of the organization the application user belongs to.", Required: true, }, + "user_id": schema.StringAttribute{ + Description: "Identifier of the organization application user.", + Required: true, + }, "name": schema.StringAttribute{ Description: "Name of the organization application user.", Computed: true, diff --git a/internal/plugin/service/organization/organization_application_user_test.go b/internal/plugin/service/organization/organization_application_user_test.go index 6a2c705fb..a068a75c7 100644 --- a/internal/plugin/service/organization/organization_application_user_test.go +++ b/internal/plugin/service/organization/organization_application_user_test.go @@ -19,7 +19,11 @@ func TestAccOrganizationApplicationUserResourceDataSource(t *testing.T) { deps.IsBeta(true) - name := "aiven_organization_application_user.foo" + names := []string{ + "aiven_organization_application_user.foo", + "aiven_organization_application_user_token.foo", + } + dname := "data.aiven_organization_application_user.foo" suffix := acctest.RandStringFromCharSet(acc.DefaultRandomSuffixLength, acctest.CharSetAlphaNum) @@ -41,19 +45,19 @@ resource "aiven_organization_application_user" "foo" { `, acc.DefaultResourceNamePrefix, suffix, deps.OrganizationName()), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr( - name, + names[0], "name", fmt.Sprintf("%s-org-appuser-%s", acc.DefaultResourceNamePrefix, suffix), ), - resource.TestCheckResourceAttrSet(name, "id"), + resource.TestCheckResourceAttrSet(names[0], "id"), ), }, { - ResourceName: name, + ResourceName: names[0], ImportState: true, ImportStateVerify: true, ImportStateIdFunc: func(state *terraform.State) (string, error) { - rs, err := acc.ResourceFromState(state, name) + rs, err := acc.ResourceFromState(state, names[0]) if err != nil { return "", err } @@ -76,7 +80,7 @@ resource "aiven_organization_application_user" "foo" { `, acc.DefaultResourceNamePrefix, suffix, deps.OrganizationName()), Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr( - name, + names[0], "name", fmt.Sprintf("%s-org-appuser-%s-1", acc.DefaultResourceNamePrefix, suffix), ), @@ -94,8 +98,8 @@ resource "aiven_organization_application_user" "foo" { } data "aiven_organization_application_user" "foo" { - user_id = aiven_organization_application_user.foo.user_id organization_id = data.aiven_organization.foo.id + user_id = aiven_organization_application_user.foo.user_id } `, acc.DefaultResourceNamePrefix, suffix, deps.OrganizationName()), Check: resource.ComposeAggregateTestCheckFunc( @@ -106,6 +110,33 @@ data "aiven_organization_application_user" "foo" { ), ), }, + { + Config: fmt.Sprintf(` +data "aiven_organization" "foo" { + name = "%[3]s" +} + +resource "aiven_organization_application_user" "foo" { + organization_id = data.aiven_organization.foo.id + name = "%[1]s-org-appuser-%[2]s-1" +} + +resource "aiven_organization_application_user_token" "foo" { + organization_id = aiven_organization_application_user.foo.organization_id + user_id = aiven_organization_application_user.foo.user_id + description = "Terraform acceptance tests" + max_age_seconds = 3600 + extend_when_used = true + scopes = ["user:read"] +} +`, acc.DefaultResourceNamePrefix, suffix, deps.OrganizationName()), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr(names[1], "description", "Terraform acceptance tests"), + resource.TestCheckResourceAttr(names[1], "max_age_seconds", "3600"), + resource.TestCheckResourceAttr(names[1], "extend_when_used", "true"), + resource.TestCheckResourceAttr(names[1], "scopes.#", "1"), + ), + }, }, }) } diff --git a/internal/plugin/service/organization/organization_application_user_token.go b/internal/plugin/service/organization/organization_application_user_token.go new file mode 100644 index 000000000..9635da9cf --- /dev/null +++ b/internal/plugin/service/organization/organization_application_user_token.go @@ -0,0 +1,432 @@ +package organization + +import ( + "context" + "fmt" + + "github.com/aiven/aiven-go-client/v2" + "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "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/numberplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/aiven/terraform-provider-aiven/internal/plugin/errmsg" + "github.com/aiven/terraform-provider-aiven/internal/plugin/util" +) + +var ( + _ resource.Resource = &organizationApplicationUserToken{} + _ resource.ResourceWithConfigure = &organizationApplicationUserToken{} + _ resource.ResourceWithImportState = &organizationApplicationUserToken{} + + _ util.TypeNameable = &organizationApplicationUserToken{} +) + +// NewOrganizationApplicationUserToken is a constructor for the organization application user token resource. +func NewOrganizationApplicationUserToken() resource.Resource { + return &organizationApplicationUserToken{} +} + +// organizationApplicationUserToken is the organization application user token resource implementation. +type organizationApplicationUserToken struct { + // client is the instance of the Aiven client to use. + client *aiven.Client + + // typeName is the name of the resource type. + typeName string +} + +// organizationApplicationUserTokenModel is the model for the organization application user token resource. +type organizationApplicationUserTokenModel struct { + // ID is the identifier of the organization application user token. + ID types.String `tfsdk:"id"` + // OrganizationID is the identifier of the organization the application user token belongs to. + OrganizationID types.String `tfsdk:"organization_id"` + // UserID is the identifier of the application user the token belongs to. + UserID types.String `tfsdk:"user_id"` + // FullToken is the full token. + FullToken types.String `tfsdk:"full_token"` + // TokenPrefix is the prefix of the token. + TokenPrefix types.String `tfsdk:"token_prefix"` + // Description is the description of the token. + Description types.String `tfsdk:"description"` + // MaxAgeSeconds is the time the token remains valid since creation (or since last use if extend_when_used + // is true). + MaxAgeSeconds types.Number `tfsdk:"max_age_seconds"` + // ExtendWhenUsed is true to extend token expiration time when token is used. Only applicable if + // max_age_seconds is specified. + ExtendWhenUsed types.Bool `tfsdk:"extend_when_used"` + // Scopes is the scopes this token is restricted to if specified. + Scopes types.Set `tfsdk:"scopes"` + // CurrentlyActive is true if API request was made with this access token. + CurrentlyActive types.Bool `tfsdk:"currently_active"` + // CreateTime is the time when the token was created. + CreateTime types.String `tfsdk:"create_time"` + // CreatedManually is true for tokens explicitly created via the access_tokens API, false for tokens created + // via login. + CreatedManually types.Bool `tfsdk:"created_manually"` + // ExpiryTime is the timestamp when the access token will expire unless extended, if ever. + ExpiryTime types.String `tfsdk:"expiry_time"` + // LastIP is the IP address of the last request made with this token. + LastIP types.String `tfsdk:"last_ip"` + // LastUsedTime is the timestamp when the access token was last used, if ever. + LastUsedTime types.String `tfsdk:"last_used_time"` + // LastUserAgent is the user agent of the last request made with this token. + LastUserAgent types.String `tfsdk:"last_user_agent"` + // LastUserAgentHumanReadable is the user agent of the last request made with this token in + // human-readable format. + LastUserAgentHumanReadable types.String `tfsdk:"last_user_agent_human_readable"` + // Timeouts is the configuration for resource-specific timeouts. + Timeouts timeouts.Value `tfsdk:"timeouts"` +} + +// Metadata returns the metadata for the organization application user token resource. +func (r *organizationApplicationUserToken) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_organization_application_user_token" + + r.typeName = resp.TypeName +} + +// TypeName returns the resource type name for the organization application user token resource. +func (r *organizationApplicationUserToken) TypeName() string { + return r.typeName +} + +// Schema defines the schema for the organization application user token resource. +func (r *organizationApplicationUserToken) Schema( + ctx context.Context, + _ resource.SchemaRequest, + resp *resource.SchemaResponse, +) { + resp.Schema = util.GeneralizeSchema(ctx, schema.Schema{ + Description: util.BetaDescription("Creates and manages an organization application user token in Aiven."), + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Compound identifier of the organization application user token.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "organization_id": schema.StringAttribute{ + Description: "Identifier of the organization the application user token belongs to.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "user_id": schema.StringAttribute{ + Description: "Identifier of the application user the token belongs to.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "full_token": schema.StringAttribute{ + Description: "Full token.", + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "token_prefix": schema.StringAttribute{ + Description: "Prefix of the token.", + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "Description of the token.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "max_age_seconds": schema.NumberAttribute{ + Description: "Time the token remains valid since creation (or since last use if " + + "extend_when_used is true).", + Optional: true, + PlanModifiers: []planmodifier.Number{ + numberplanmodifier.RequiresReplace(), + }, + }, + "extend_when_used": schema.BoolAttribute{ + Description: "True to extend token expiration time when token is used. Only applicable if " + + "max_age_seconds is specified.", + Optional: true, + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + "scopes": schema.SetAttribute{ + Description: "Scopes this token is restricted to if specified.", + Optional: true, + ElementType: types.StringType, + PlanModifiers: []planmodifier.Set{ + setplanmodifier.RequiresReplace(), + }, + }, + "currently_active": schema.BoolAttribute{ + Description: "True if API request was made with this access token.", + Computed: true, + }, + "create_time": schema.StringAttribute{ + Description: "Time when the token was created.", + Computed: true, + }, + "created_manually": schema.BoolAttribute{ + Description: "True for tokens explicitly created via the access_tokens API, false for tokens created " + + "via login.", + Computed: true, + }, + "expiry_time": schema.StringAttribute{ + Description: "Timestamp when the access token will expire unless extended, if ever.", + Computed: true, + }, + "last_ip": schema.StringAttribute{ + Description: "IP address of the last request made with this token.", + Computed: true, + }, + "last_used_time": schema.StringAttribute{ + Description: "Timestamp when the access token was last used, if ever.", + Computed: true, + }, + "last_user_agent": schema.StringAttribute{ + Description: "User agent of the last request made with this token.", + Computed: true, + }, + "last_user_agent_human_readable": schema.StringAttribute{ + Description: "User agent of the last request made with this token in human-readable format.", + Computed: true, + }, + }, + }) +} + +// Configure sets up the organization application user token resource. +func (r *organizationApplicationUserToken) Configure( + _ context.Context, + req resource.ConfigureRequest, + resp *resource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*aiven.Client) + if !ok { + resp.Diagnostics = util.DiagErrorUnexpectedProviderDataType(resp.Diagnostics, req.ProviderData) + + return + } + + r.client = client +} + +// fillModel fills the organization application user token resource model from the Aiven API. +func (r *organizationApplicationUserToken) fillModel( + ctx context.Context, + model *organizationApplicationUserTokenModel, +) (err error) { + tokens, err := r.client.OrganizationApplicationUserHandler.ListTokens( + ctx, + model.OrganizationID.ValueString(), + model.UserID.ValueString(), + ) + if err != nil { + return err + } + + var token *aiven.ApplicationUserTokenInfo + + for _, t := range tokens.Tokens { + if t.TokenPrefix == model.TokenPrefix.ValueString() { + token = &t + break + } + } + + if token == nil { + return fmt.Errorf(errmsg.AivenResourceNotFound, r.TypeName(), model.ID.ValueString()) + } + + model.Description = util.ValueOrDefault(token.Description, types.StringNull()) + + model.MaxAgeSeconds = types.NumberValue(util.ToBigFloat(token.MaxAgeSeconds)) + + model.ExtendWhenUsed = util.ValueOrDefault(token.ExtendWhenUsed, types.BoolNull()) + + if token.Scopes != nil { + scopes, diags := types.SetValueFrom(ctx, types.StringType, *token.Scopes) + if diags.HasError() { + return fmt.Errorf(errmsg.UnableToSetValueFrom, *token.Scopes) + } + + model.Scopes = scopes + } + + model.CurrentlyActive = types.BoolValue(token.CurrentlyActive) + + model.CreateTime = util.ValueOrDefault(token.CreateTime, types.StringNull()) + + model.CreatedManually = types.BoolValue(token.CreatedManually) + + model.ExpiryTime = util.ValueOrDefault(token.ExpiryTime, types.StringNull()) + + model.LastIP = types.StringValue(token.LastIP) + + model.LastUsedTime = util.ValueOrDefault(token.LastUsedTime, types.StringNull()) + + model.LastUserAgent = util.ValueOrDefault(token.LastUserAgent, types.StringNull()) + + model.LastUserAgentHumanReadable = util.ValueOrDefault(token.LastUserAgentHumanReadable, types.StringNull()) + + model.TokenPrefix = types.StringValue(token.TokenPrefix) + + return err +} + +// Create creates an organization application user token resource. +func (r *organizationApplicationUserToken) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + var plan organizationApplicationUserTokenModel + + if !util.PlanStateToModel(ctx, &req.Plan, &plan, &resp.Diagnostics) { + return + } + + aivenReq := &aiven.ApplicationUserTokenCreateRequest{} + + if !plan.Description.IsNull() { + aivenReq.Description = util.Ref(plan.Description.ValueString()) + } + + if !plan.MaxAgeSeconds.IsNull() { + aivenReq.MaxAgeSeconds = util.Ref((int)(util.First(plan.MaxAgeSeconds.ValueBigFloat().Int64()))) + } + + if !plan.ExtendWhenUsed.IsNull() { + aivenReq.ExtendWhenUsed = util.Ref(plan.ExtendWhenUsed.ValueBool()) + } + + if !plan.Scopes.IsNull() { + scopes := make([]string, 0, len(plan.Scopes.Elements())) + + diags := plan.Scopes.ElementsAs(ctx, &scopes, false) + if diags.HasError() { + resp.Diagnostics = append(resp.Diagnostics, diags...) + + return + } + + aivenReq.Scopes = &scopes + } + + token, err := r.client.OrganizationApplicationUserHandler.CreateToken( + ctx, + plan.OrganizationID.ValueString(), + plan.UserID.ValueString(), + *aivenReq, + ) + if err != nil { + resp.Diagnostics = util.DiagErrorCreatingResource(resp.Diagnostics, r, err) + + return + } + + plan.ID = types.StringValue( + util.ComposeID(plan.OrganizationID.ValueString(), plan.UserID.ValueString(), token.TokenPrefix), + ) + + plan.FullToken = types.StringValue(token.FullToken) + + plan.TokenPrefix = types.StringValue(token.TokenPrefix) + + err = r.fillModel(ctx, &plan) + if err != nil { + resp.Diagnostics = util.DiagErrorCreatingResource(resp.Diagnostics, r, err) + + return + } + + if !util.ModelToPlanState(ctx, plan, &resp.State, &resp.Diagnostics) { + return + } +} + +// Read reads an organization application user token resource. +func (r *organizationApplicationUserToken) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + var state organizationApplicationUserTokenModel + + if !util.PlanStateToModel(ctx, &req.State, &state, &resp.Diagnostics) { + return + } + + err := r.fillModel(ctx, &state) + if err != nil { + resp.Diagnostics = util.DiagErrorReadingResource(resp.Diagnostics, r, err) + + return + } + + if !util.ModelToPlanState(ctx, state, &resp.State, &resp.Diagnostics) { + return + } +} + +// Update updates an organization resource. +func (r *organizationApplicationUserToken) Update( + _ context.Context, + _ resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + resp.Diagnostics = util.DiagErrorUpdatingResourceNotSupported(resp.Diagnostics, r) +} + +// Delete deletes an organization application user token resource. +func (r *organizationApplicationUserToken) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + var state organizationApplicationUserTokenModel + + if !util.PlanStateToModel(ctx, &req.State, &state, &resp.Diagnostics) { + return + } + + if err := r.client.OrganizationApplicationUserHandler.DeleteToken( + ctx, + state.OrganizationID.ValueString(), + state.UserID.ValueString(), + state.TokenPrefix.ValueString(), + ); err != nil { + resp.Diagnostics = util.DiagErrorDeletingResource(resp.Diagnostics, r, err) + + return + } +} + +// ImportState handles resource's state import requests. +func (r *organizationApplicationUserToken) ImportState( + _ context.Context, + _ resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { + resp.Diagnostics = util.DiagErrorImportingResourceNotSupported(resp.Diagnostics, r) +} diff --git a/internal/plugin/service/organization/organization_user_group_member.go b/internal/plugin/service/organization/organization_user_group_member.go index 4d5bf3bb6..60db8db91 100644 --- a/internal/plugin/service/organization/organization_user_group_member.go +++ b/internal/plugin/service/organization/organization_user_group_member.go @@ -6,6 +6,7 @@ import ( "github.com/aiven/aiven-go-client/v2" "github.com/hashicorp/terraform-plugin-framework-timeouts/resource/timeouts" + "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/planmodifier" @@ -38,12 +39,14 @@ type organizationUserGroupMembersResource struct { // organizationUserGroupMembersResourceModel is the model for the organization user group member resource. type organizationUserGroupMembersResourceModel struct { - // ID is the identifier of the organization. + // ID is the compound identifier of the organization user group member. + ID types.String `tfsdk:"id"` + // OrganizationID is the identifier of the organization. OrganizationID types.String `tfsdk:"organization_id"` - // ID is the identifier of the organization user group. - OrganizationGroupID types.String `tfsdk:"group_id"` - // ID is the identifier of the organization user group member. - OrganizationUserID types.String `tfsdk:"user_id"` + // GroupID is the identifier of the organization user group. + GroupID types.String `tfsdk:"group_id"` + // UserID is the identifier of the organization user group member. + UserID types.String `tfsdk:"user_id"` // Last activity time of the user group member. LastActivityTime types.String `tfsdk:"last_activity_time"` // Timeouts is the configuration for resource-specific timeouts. @@ -75,6 +78,13 @@ func (r *organizationUserGroupMembersResource) Schema( resp.Schema = util.GeneralizeSchema(ctx, schema.Schema{ Description: util.BetaDescription("Adds and manages users in a user group."), Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Compound identifier of the organization user group member.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, "organization_id": schema.StringAttribute{ Description: "Identifier of the organization.", Required: true, @@ -124,7 +134,7 @@ func (r *organizationUserGroupMembersResource) Configure( r.client = client } -// TimeoutSchema returns the schema for resource-specific timeouts. +// fillModel fills the organization group project relation model from the Aiven API. func (r *organizationUserGroupMembersResource) fillModel( ctx context.Context, model *organizationUserGroupMembersResourceModel, @@ -132,7 +142,7 @@ func (r *organizationUserGroupMembersResource) fillModel( list, err := r.client.OrganizationUserGroupMembers.List( ctx, model.OrganizationID.ValueString(), - model.OrganizationGroupID.ValueString(), + model.GroupID.ValueString(), ) if err != nil { return err @@ -145,7 +155,7 @@ func (r *organizationUserGroupMembersResource) fillModel( var member *aiven.OrganizationUserGroupMember for _, m := range list.Members { - if m.UserID == model.OrganizationUserID.ValueString() { + if m.UserID == model.UserID.ValueString() { member = &m break } @@ -157,8 +167,8 @@ func (r *organizationUserGroupMembersResource) fillModel( r.TypeName(), util.ComposeID( model.OrganizationID.ValueString(), - model.OrganizationGroupID.ValueString(), - model.OrganizationUserID.ValueString(), + model.GroupID.ValueString(), + model.UserID.ValueString(), ), ) } @@ -183,11 +193,11 @@ func (r *organizationUserGroupMembersResource) Create( err := r.client.OrganizationUserGroupMembers.Modify( ctx, plan.OrganizationID.ValueString(), - plan.OrganizationGroupID.ValueString(), + plan.GroupID.ValueString(), aiven.OrganizationUserGroupMemberRequest{ Operation: "add_members", MemberIDs: []string{ - plan.OrganizationUserID.ValueString(), + plan.UserID.ValueString(), }, }, ) @@ -197,6 +207,10 @@ func (r *organizationUserGroupMembersResource) Create( return } + plan.ID = types.StringValue( + util.ComposeID(plan.OrganizationID.ValueString(), plan.GroupID.ValueString(), plan.UserID.ValueString()), + ) + err = r.fillModel(ctx, &plan) if err != nil { resp.Diagnostics = util.DiagErrorCreatingResource(resp.Diagnostics, r, err) @@ -257,11 +271,11 @@ func (r *organizationUserGroupMembersResource) Delete( if err := r.client.OrganizationUserGroupMembers.Modify( ctx, plan.OrganizationID.ValueString(), - plan.OrganizationGroupID.ValueString(), + plan.GroupID.ValueString(), aiven.OrganizationUserGroupMemberRequest{ Operation: "remove_members", MemberIDs: []string{ - plan.OrganizationGroupID.ValueString(), + plan.GroupID.ValueString(), }, }, ); err != nil { @@ -277,5 +291,7 @@ func (r *organizationUserGroupMembersResource) ImportState( req resource.ImportStateRequest, resp *resource.ImportStateResponse, ) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) + util.UnpackCompoundID(ctx, req, resp, "organization_id", "group_id", "user_id") } diff --git a/internal/plugin/service/organization/organization_user_group_member_test.go b/internal/plugin/service/organization/organization_user_group_member_test.go index 8854a35bd..1fa94b160 100644 --- a/internal/plugin/service/organization/organization_user_group_member_test.go +++ b/internal/plugin/service/organization/organization_user_group_member_test.go @@ -45,16 +45,15 @@ resource "aiven_organization_user_group_member" "foo" { group_id = aiven_organization_user_group.foo.group_id user_id = "%[4]s" } - `, acc.DefaultResourceNamePrefix, suffix, deps.OrganizationName(), util.Deref(userID)), + `, acc.DefaultResourceNamePrefix, suffix, deps.OrganizationName(), *userID), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttrSet(name, "last_activity_time"), ), }, { - ResourceName: name, - ImportState: true, - ImportStateVerify: true, - ImportStateVerifyIdentifierAttribute: "user_id", + ResourceName: name, + ImportState: true, + ImportStateVerify: true, ImportStateIdFunc: func(state *terraform.State) (string, error) { rs, err := acc.ResourceFromState(state, name) if err != nil { diff --git a/internal/plugin/util/diag.go b/internal/plugin/util/diag.go index 7815ce476..dc134a958 100644 --- a/internal/plugin/util/diag.go +++ b/internal/plugin/util/diag.go @@ -53,6 +53,8 @@ func DiagErrorUpdatingResource(diagnostics diag.Diagnostics, typenameable TypeNa return diagnostics } +// DiagErrorUpdatingResourceNotSupported is a function that adds a resource updating not supported error to the +// diagnostics and returns it. It is used in the Update method of the resource structs. func DiagErrorUpdatingResourceNotSupported(diagnostics diag.Diagnostics, typenameable TypeNameable) diag.Diagnostics { diagnostics.AddError( errmsg.SummaryErrorUpdatingResource, @@ -73,6 +75,17 @@ func DiagErrorDeletingResource(diagnostics diag.Diagnostics, typenameable TypeNa return diagnostics } +// DiagErrorImportingResourceNotSupported is a function that adds a resource importing not supported error to the +// diagnostics and returns it. It is used in the ImportState method of the resource structs. +func DiagErrorImportingResourceNotSupported(diagnostics diag.Diagnostics, typenameable TypeNameable) diag.Diagnostics { + diagnostics.AddError( + errmsg.SummaryErrorImportingResource, + fmt.Sprintf(errmsg.DetailErrorImportingResourceNotSupported, typenameable.TypeName()), + ) + + return diagnostics +} + // DiagErrorReadingDataSource is a function that adds a data source reading error to the diagnostics and returns it. // It is used in the Read method of the data source structs. func DiagErrorReadingDataSource(diagnostics diag.Diagnostics, typenameable TypeNameable, err error) diag.Diagnostics { diff --git a/internal/plugin/util/helpers.go b/internal/plugin/util/helpers.go index 9fe43ccfa..c9d08436e 100644 --- a/internal/plugin/util/helpers.go +++ b/internal/plugin/util/helpers.go @@ -1,10 +1,15 @@ package util import ( - "os" - "strings" + "math/big" + + "golang.org/x/exp/constraints" ) +// This file contains helper functions that are more generic and can be used in multiple places. +// These functions are not specific to the Aiven plugin. If you are looking for Aiven plugin specific helpers, +// please see the pluginhelpers.go file instead. + // Ref is a helper function that returns a pointer to the value passed in. func Ref[T any](v T) *T { return &v @@ -21,20 +26,12 @@ func Deref[T any](p *T) T { return result } -// IsBeta is a helper function that returns a flag that indicates whether the provider is in beta mode. -// This SHOULD NOT be used anywhere else except in the provider and acceptance tests initialization. -// In case this functionality is needed in tests, please use the acctest.CommonTestDependencies.IsBeta() function. -func IsBeta() bool { - return os.Getenv("PROVIDER_AIVEN_ENABLE_BETA") != "" -} - -// ComposeID is a helper function that composes an ID from the parts passed in. -func ComposeID(parts ...string) string { - return strings.Join(parts, "/") +// First is a helper function that returns the first argument passed in out of two. +func First[T any, U any](a T, _ U) T { + return a } -// BetaDescription is a helper function that returns a description for beta resources. -func BetaDescription(description string) string { - return description + " Please note that this resource is in beta and may change without notice. " + - "To use this resource, please set the PROVIDER_AIVEN_ENABLE_BETA environment variable." +// ToBigFloat is a helper function that converts any integer or float type to a big.Float. +func ToBigFloat[T constraints.Integer | constraints.Float](v T) *big.Float { + return big.NewFloat(float64(v)) } diff --git a/internal/plugin/util/pluginhelpers.go b/internal/plugin/util/pluginhelpers.go new file mode 100644 index 000000000..e120293d9 --- /dev/null +++ b/internal/plugin/util/pluginhelpers.go @@ -0,0 +1,67 @@ +package util + +import ( + "os" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-framework/types" +) + +const ( + // errTerraformTypeAssertionFailed is an error that is returned when a Terraform type assertion fails. + errTerraformTypeAssertionFailed = "terraform type assertion failed" +) + +// IsBeta is a helper function that returns a flag that indicates whether the provider is in beta mode. +// This SHOULD NOT be used anywhere else except in the provider and acceptance tests initialization. +// In case this functionality is needed in tests, please use the acctest.CommonTestDependencies.IsBeta() function. +func IsBeta() bool { + return os.Getenv("PROVIDER_AIVEN_ENABLE_BETA") != "" +} + +// ComposeID is a helper function that composes an ID from the parts passed in. +func ComposeID(parts ...string) string { + return strings.Join(parts, "/") +} + +// BetaDescription is a helper function that returns a description for beta resources. +func BetaDescription(description string) string { + return description + " Please note that this resource is in beta and may change without notice. " + + "To use this resource, please set the PROVIDER_AIVEN_ENABLE_BETA environment variable." +} + +// ValueOrDefault returns the value if not nil, otherwise returns the default value. Value is converted to type +// U if possible. If the conversion is not possible, the function panics. +// +// The null value of the Terraform type should be used for the default value unless you know what you are doing, e.g. +// types.StringNull() should be used for strings, types.BoolNull() should be used for booleans. +func ValueOrDefault[T comparable, U types.Bool | types.String](value *T, defaultValue U) U { + if value != nil { + switch v := any(*value).(type) { + case bool: + if bv, ok := any(types.BoolValue(v)).(U); ok { + return bv + } + case string, time.Time: + // time.Time is also a string in the state, so we need to handle it here. + + var str string + + switch v := v.(type) { + case string: + str = v + case time.Time: + str = v.String() + } + + if sv, ok := any(types.StringValue(str)).(U); ok { + return sv + } + default: + panic(errTerraformTypeAssertionFailed) + } + } + + return defaultValue +} diff --git a/internal/schemautil/plugin.go b/internal/schemautil/plugin.go deleted file mode 100644 index 738624803..000000000 --- a/internal/schemautil/plugin.go +++ /dev/null @@ -1,154 +0,0 @@ -package schemautil - -import ( - "context" - "encoding/json" - "reflect" - - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/liip/sheriff" -) - -func ExpandSet[T any](ctx context.Context, diags *diag.Diagnostics, list types.Set) (items []T) { - if list.IsUnknown() || list.IsNull() { - return nil - } - diags.Append(list.ElementsAs(ctx, &items, false)...) - return items -} - -type Expander[T, K any] func(ctx context.Context, diags *diag.Diagnostics, o *T) *K - -func ExpandSetNested[T, K any](ctx context.Context, diags *diag.Diagnostics, expand Expander[T, K], list types.Set) []*K { - expanded := ExpandSet[T](ctx, diags, list) - if expanded == nil || diags.HasError() { - return nil - } - - items := make([]*K, 0, len(expanded)) - for _, v := range expanded { - items = append(items, expand(ctx, diags, &v)) - if diags.HasError() { - return make([]*K, 0) - } - } - return items -} - -func ExpandSetBlockNested[T, K any](ctx context.Context, diags *diag.Diagnostics, expand Expander[T, K], list types.Set) *K { - items := ExpandSetNested(ctx, diags, expand, list) - if len(items) == 0 { - return nil - } - return items[0] -} - -type Flattener[T, K any] func(ctx context.Context, diags *diag.Diagnostics, o *T) *K - -func FlattenSetNested[T, K any](ctx context.Context, diags *diag.Diagnostics, flatten Flattener[T, K], attrs map[string]attr.Type, list []*T) types.Set { - oType := types.ObjectType{AttrTypes: attrs} - empty := types.SetValueMust(oType, []attr.Value{}) - items := make([]*K, 0, len(list)) - for _, v := range list { - items = append(items, flatten(ctx, diags, v)) - if diags.HasError() { - return empty - } - } - - result, d := types.SetValueFrom(ctx, oType, items) - diags.Append(d...) - if diags.HasError() { - return empty - } - return result -} - -func FlattenSetBlockNested[T, K any](ctx context.Context, diags *diag.Diagnostics, flatten Flattener[T, K], attrs map[string]attr.Type, o *T) types.Set { - if o == nil { - return types.SetValueMust(types.ObjectType{AttrTypes: attrs}, []attr.Value{}) - } - return FlattenSetNested(ctx, diags, flatten, attrs, []*T{o}) -} - -// marshalUserConfig converts user config into json -func marshalUserConfig(c any, groups ...string) (map[string]any, error) { - if c == nil || (reflect.ValueOf(c).Kind() == reflect.Ptr && reflect.ValueOf(c).IsNil()) { - return nil, nil - } - - o := &sheriff.Options{ - Groups: groups, - } - - i, err := sheriff.Marshal(o, c) - if err != nil { - return nil, err - } - - m, ok := i.(map[string]any) - if !ok { - // It is an empty pointer - // sheriff just returned the very same object - return nil, nil - } - - return m, nil -} - -func MarshalUserConfig(c any, create bool) (map[string]any, error) { - if create { - return marshalUserConfig(c, "create", "update") - } - return marshalUserConfig(c, "update") -} - -func MapToDTO(src map[string]any, dst any) error { - b, err := json.Marshal(&src) - if err != nil { - return err - } - return json.Unmarshal(b, dst) -} - -// ValueStringPointer checks for "unknown" -// Returns nil instead of zero value -func ValueStringPointer(v types.String) *string { - if v.IsUnknown() || v.IsNull() { - return nil - } - return v.ValueStringPointer() -} - -// ValueBoolPointer checks for "unknown" -// Returns nil instead of zero value -func ValueBoolPointer(v types.Bool) *bool { - if v.IsUnknown() || v.IsNull() { - return nil - } - return v.ValueBoolPointer() -} - -// ValueInt64Pointer checks for "unknown" -// Returns nil instead of zero value -func ValueInt64Pointer(v types.Int64) *int64 { - if v.IsUnknown() || v.IsNull() { - return nil - } - return v.ValueInt64Pointer() -} - -// ValueFloat64Pointer checks for "unknown" -// Returns nil instead of zero value -func ValueFloat64Pointer(v types.Float64) *float64 { - if v.IsUnknown() || v.IsNull() { - return nil - } - return v.ValueFloat64Pointer() -} - -func HasValue(o types.Set) bool { - return !(o.IsUnknown() || o.IsNull()) -}