Files

730 lines
23 KiB
Go

package resource
import (
"context"
"errors"
"fmt"
kmsclient "github.com/hanzokms/terraform-provider/internal/client"
pkg "github.com/hanzokms/terraform-provider/internal/pkg/strings"
"strings"
"time"
"github.com/hashicorp/go-uuid"
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"github.com/hashicorp/terraform-plugin-framework/diag"
"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"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/tfsdk"
"github.com/hashicorp/terraform-plugin-framework/types"
)
// Ensure the implementation satisfies the expected interfaces.
var (
_ resource.Resource = &secretResource{}
)
// NewsecretResource is a helper function to simplify the provider implementation.
func NewSecretResource() resource.Resource {
return &secretResource{}
}
// secretResource is the resource implementation.
type secretResource struct {
client *kmsclient.Client
}
type SecretReminder struct {
Note types.String `tfsdk:"note"`
RepeatDays types.Int64 `tfsdk:"repeat_days"`
}
// secretResourceSourceModel describes the data source data model.
type secretResourceModel struct {
FolderPath types.String `tfsdk:"folder_path"`
EnvSlug types.String `tfsdk:"env_slug"`
Name types.String `tfsdk:"name"`
SecretReminder *SecretReminder `tfsdk:"secret_reminder"`
Value types.String `tfsdk:"value"`
ValueWO types.String `tfsdk:"value_wo"`
ValueWOVersion types.Int64 `tfsdk:"value_wo_version"`
WorkspaceId types.String `tfsdk:"workspace_id"`
LastUpdated types.String `tfsdk:"last_updated"`
Tags types.List `tfsdk:"tag_ids"`
Metadata types.Map `tfsdk:"metadata"`
ID types.String `tfsdk:"id"`
}
type SecretData struct {
IsWriteOnly bool
ShouldUpdateValue bool
Value string
}
func (m *secretResourceModel) getSecretValue(ctx context.Context, config tfsdk.Config, state *tfsdk.State, diags *diag.Diagnostics) (SecretData, error) {
// check if normal value was configured
if !m.Value.IsNull() && !m.Value.IsUnknown() {
return SecretData{
IsWriteOnly: false,
ShouldUpdateValue: true,
Value: m.Value.ValueString(),
}, nil
}
// check write-only value if the normal value isn't set
var secretValue types.String
diags.Append(config.GetAttribute(ctx, path.Root("value_wo"), &secretValue)...)
if diags.HasError() {
return SecretData{}, errors.New("failed to get write-only secret value")
}
if !secretValue.IsNull() && !secretValue.IsUnknown() {
shouldUpdateValue := true
// if state exists we know its an update operation, so we need to compare versions
if state != nil && !state.Raw.IsNull() {
var newVersion types.Int64
diags.Append(config.GetAttribute(ctx, path.Root("value_wo_version"), &newVersion)...)
var stateModel secretResourceModel
diags.Append(state.Get(ctx, &stateModel)...)
if !diags.HasError() {
oldVersion := int64(0)
if !stateModel.ValueWOVersion.IsNull() {
oldVersion = stateModel.ValueWOVersion.ValueInt64()
}
if oldVersion > newVersion.ValueInt64() {
return SecretData{}, errors.New("new value write-only version is less than old version")
}
shouldUpdateValue = newVersion.ValueInt64() != oldVersion
}
}
return SecretData{
IsWriteOnly: true,
Value: secretValue.ValueString(),
ShouldUpdateValue: shouldUpdateValue,
}, nil
}
return SecretData{}, errors.New("no secret value provided")
}
// Metadata returns the resource type name.
func (r *secretResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_secret"
}
// Schema defines the schema for the resource.
func (r *secretResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
resp.Schema = schema.Schema{
Description: "Create secrets & save to Kms",
Attributes: map[string]schema.Attribute{
"folder_path": schema.StringAttribute{
Description: "The path to the folder where the given secret resides",
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
Required: true,
Computed: false,
},
"env_slug": schema.StringAttribute{
Description: "The environment slug of the secret to modify/create",
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
Required: true,
Computed: false,
},
"name": schema.StringAttribute{
Description: "The name of the secret",
Required: true,
Computed: false,
},
"value": schema.StringAttribute{
Description: "The value of the secret in plain text. This is required if `value_wo` is not set.",
Validators: []validator.String{
stringvalidator.ConflictsWith(path.MatchRoot("value_wo")),
stringvalidator.ConflictsWith(path.MatchRoot("value_wo_version")),
},
Optional: true,
Computed: false,
Sensitive: true,
},
"value_wo": schema.StringAttribute{
Description: "The value of the secret in plain text as a write-only secret. If set, the secret value will not be stored in state. This is required if `value` is not set. Requires Terraform version 1.11.0 or higher.",
Optional: true,
Computed: false,
WriteOnly: true,
Validators: []validator.String{
stringvalidator.AlsoRequires(path.Expressions{
path.MatchRoot("value_wo_version"),
}...),
},
},
"value_wo_version": schema.Int64Attribute{
Description: "Used together with value_wo to trigger an update. Increment this value when an update to the value_wo is required.",
Optional: true,
Validators: []validator.Int64{
int64validator.AtLeast(1),
int64validator.AlsoRequires(path.Expressions{
path.MatchRoot("value_wo"),
}...),
},
},
"workspace_id": schema.StringAttribute{
Description: "The Kms project ID (Required for Machine Identity auth, and service tokens with multiple scopes)",
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown(), stringplanmodifier.RequiresReplace()},
Optional: true,
Computed: true,
},
"last_updated": schema.StringAttribute{
Computed: true,
},
"tag_ids": schema.ListAttribute{
ElementType: types.StringType,
Optional: true,
Description: "Tag ids to be attached for the secrets.",
},
"metadata": schema.MapAttribute{
ElementType: types.StringType,
Optional: true,
Description: "Metadata associated with the secret as key-value pairs.",
},
"secret_reminder": schema.SingleNestedAttribute{
Attributes: map[string]schema.Attribute{
"note": schema.StringAttribute{
Description: "Note for the secret rotation reminder",
Computed: false,
Optional: true,
},
"repeat_days": schema.Int64Attribute{
Description: "Frequency of secret rotation reminder in days",
Computed: false,
Required: true,
Validators: []validator.Int64{
int64validator.AtLeast(1),
int64validator.AtMost(365),
},
},
},
Optional: true,
Computed: false,
},
"id": schema.StringAttribute{
Description: "The ID of the secret",
Computed: true,
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
},
}
}
// Configure adds the provider configured client to the resource.
func (r *secretResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
if req.ProviderData == nil {
return
}
client, ok := req.ProviderData.(*kmsclient.Client)
if !ok {
resp.Diagnostics.AddError(
"Unexpected Data Source Configure Type",
fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData),
)
return
}
r.client = client
}
// Create creates the resource and sets the initial Terraform state.
func (r *secretResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
// Retrieve values from plan
var plan secretResourceModel
diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
secretData, err := plan.getSecretValue(ctx, req.Config, nil, &resp.Diagnostics)
if err != nil {
resp.Diagnostics.AddError(
"Error creating secret",
"Could not get secret value, unexpected error: "+err.Error(),
)
return
}
planSecretTagIds := make([]types.String, 0, len(plan.Tags.Elements()))
diags = plan.Tags.ElementsAs(ctx, &planSecretTagIds, false)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
secretTagIds := make([]string, 0, len(planSecretTagIds))
for _, slug := range planSecretTagIds {
secretTagIds = append(secretTagIds, strings.ToLower(slug.ValueString()))
}
planMetadata := make(map[string]types.String, 0)
if !plan.Metadata.IsNull() && !plan.Metadata.IsUnknown() {
diags = plan.Metadata.ElementsAs(ctx, &planMetadata, false)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
secretMetadata := make([]kmsclient.SecretMetadataItem, 0, len(planMetadata))
for key, value := range planMetadata {
secretMetadata = append(secretMetadata, kmsclient.SecretMetadataItem{
Key: key,
Value: value.ValueString(),
})
}
var workspaceId string
if r.client.Config.AuthStrategy == kmsclient.AuthStrategy.SERVICE_TOKEN {
serviceTokenDetails, err := r.client.GetServiceTokenDetailsV2()
if err != nil {
resp.Diagnostics.AddError(
"Error creating secret",
"Could not get service token details, unexpected error: "+err.Error(),
)
return
}
workspaceId = serviceTokenDetails.Workspace
} else if r.client.Config.IsMachineIdentityAuth {
workspaceId = plan.WorkspaceId.ValueString()
} else {
resp.Diagnostics.AddError(
"Error creating secret",
"Unknown authentication strategy",
)
return
}
// null check secret reminder
var secretReminderNote string
var secretReminderRepeatDays int64
if plan.SecretReminder != nil {
secretReminderNote = plan.SecretReminder.Note.ValueString()
secretReminderRepeatDays = plan.SecretReminder.RepeatDays.ValueInt64()
}
secret, err := r.client.CreateRawSecretsV3(kmsclient.CreateRawSecretV3Request{
Environment: plan.EnvSlug.ValueString(),
WorkspaceID: workspaceId,
Type: "shared",
SecretPath: plan.FolderPath.ValueString(),
SecretReminderNote: secretReminderNote,
SecretReminderRepeatDays: secretReminderRepeatDays,
SecretKey: plan.Name.ValueString(),
SecretValue: secretData.Value,
TagIDs: secretTagIds,
SecretMetadata: secretMetadata,
})
if err != nil {
resp.Diagnostics.AddError(
"Error creating secret",
"Couldn't save encrypted secrets to Infiscial, unexpected error: "+err.Error(),
)
return
}
plan.ID = types.StringValue(secret.ID)
if len(secret.SecretMetadata) > 0 {
metadataMap := make(map[string]types.String, len(secret.SecretMetadata))
for _, item := range secret.SecretMetadata {
metadataMap[item.Key] = types.StringValue(item.Value)
}
plan.Metadata, diags = types.MapValueFrom(ctx, types.StringType, metadataMap)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
} else if plan.Metadata.IsNull() || plan.Metadata.IsUnknown() {
plan.Metadata = types.MapNull(types.StringType)
}
plan.WorkspaceId = types.StringValue(workspaceId)
plan.LastUpdated = types.StringValue(time.Now().Format(time.RFC850))
diags = resp.State.Set(ctx, plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
// Read refreshes the Terraform state with the latest data.
func (r *secretResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
// Get current state
var state secretResourceModel
diags := req.State.Get(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
// Get refreshed order value from HashiCups
response, err := r.client.GetSingleRawSecretByNameV3(kmsclient.GetSingleSecretByNameV3Request{
SecretName: state.Name.ValueString(),
Type: "shared",
WorkspaceId: state.WorkspaceId.ValueString(),
Environment: state.EnvSlug.ValueString(),
SecretPath: state.FolderPath.ValueString(),
})
if err != nil {
if err == kmsclient.ErrNotFound {
resp.State.RemoveResource(ctx)
} else {
resp.Diagnostics.AddError(
"Error Reading Kms secret",
"Could not read Kms secret named "+state.Name.ValueString()+": "+err.Error(),
)
}
return
}
state.Name = types.StringValue(response.Secret.SecretKey)
state.ID = types.StringValue(response.Secret.ID)
if !state.Value.IsNull() && !state.Value.IsUnknown() {
// Resource was configured with regular Value field
state.Value = types.StringValue(response.Secret.SecretValue)
}
if len(response.Secret.SecretMetadata) > 0 {
metadataMap := make(map[string]types.String, len(response.Secret.SecretMetadata))
for _, item := range response.Secret.SecretMetadata {
metadataMap[item.Key] = types.StringValue(item.Value)
}
state.Metadata, diags = types.MapValueFrom(ctx, types.StringType, metadataMap)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
} else {
state.Metadata = types.MapNull(types.StringType)
}
// Set refreshed state
diags = resp.State.Set(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
// Update updates the resource and sets the updated Terraform state on success.
func (r *secretResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
// Retrieve values from plan
var plan secretResourceModel
diags := req.Plan.Get(ctx, &plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
secretData, err := plan.getSecretValue(ctx, req.Config, &req.State, &resp.Diagnostics)
if err != nil {
resp.Diagnostics.AddError(
"Error updating secret",
"Could not get secret value, unexpected error: "+err.Error(),
)
return
}
var state secretResourceModel
diagsFromState := req.State.Get(ctx, &state)
resp.Diagnostics.Append(diagsFromState...)
if resp.Diagnostics.HasError() {
return
}
planSecretTagIds := make([]types.String, 0, len(plan.Tags.Elements()))
diags = plan.Tags.ElementsAs(ctx, &planSecretTagIds, false)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
secretTagIds := make([]string, 0, len(planSecretTagIds))
for _, slug := range planSecretTagIds {
secretTagIds = append(secretTagIds, strings.ToLower(slug.ValueString()))
}
planMetadata := make(map[string]types.String, 0)
if !plan.Metadata.IsNull() && !plan.Metadata.IsUnknown() {
diags = plan.Metadata.ElementsAs(ctx, &planMetadata, false)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
secretMetadata := make([]kmsclient.SecretMetadataItem, 0, len(planMetadata))
for key, value := range planMetadata {
secretMetadata = append(secretMetadata, kmsclient.SecretMetadataItem{
Key: key,
Value: value.ValueString(),
})
}
// null check secret reminder
var secretReminderNote string
var secretReminderRepeatDays int64
if plan.SecretReminder != nil {
secretReminderNote = plan.SecretReminder.Note.ValueString()
secretReminderRepeatDays = plan.SecretReminder.RepeatDays.ValueInt64()
}
var workspaceId string
if r.client.Config.AuthStrategy == kmsclient.AuthStrategy.SERVICE_TOKEN {
serviceTokenDetails, err := r.client.GetServiceTokenDetailsV2()
if err != nil {
resp.Diagnostics.AddError(
"Error updating secret",
"Could not get service token details, unexpected error: "+err.Error(),
)
return
}
workspaceId = serviceTokenDetails.Workspace
} else if r.client.Config.IsMachineIdentityAuth {
workspaceId = plan.WorkspaceId.ValueString()
} else {
resp.Diagnostics.AddError(
"Error updating secret",
"Unknown authentication strategy",
)
return
}
updateRequest := kmsclient.UpdateRawSecretByNameV3Request{
Environment: plan.EnvSlug.ValueString(),
WorkspaceID: workspaceId,
Type: "shared",
TagIDs: secretTagIds,
SecretPath: plan.FolderPath.ValueString(),
SecretName: state.Name.ValueString(),
SecretReminderNote: secretReminderNote,
SecretReminderRepeatDays: secretReminderRepeatDays,
SecretMetadata: secretMetadata,
}
if secretData.ShouldUpdateValue {
updateRequest.SecretValue = pkg.StringToPtr(secretData.Value)
}
if plan.Name.ValueString() != state.Name.ValueString() {
updateRequest.NewSecretName = plan.Name.ValueString()
}
err = r.client.UpdateRawSecretV3(updateRequest)
if err != nil {
resp.Diagnostics.AddError(
"Error updating secret",
"Couldn't save encrypted secrets to Infiscial, unexpected error: "+err.Error(),
)
return
}
if len(secretMetadata) > 0 {
metadataMap := make(map[string]types.String, len(secretMetadata))
for _, item := range secretMetadata {
metadataMap[item.Key] = types.StringValue(item.Value)
}
plan.Metadata, diags = types.MapValueFrom(ctx, types.StringType, metadataMap)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
} else if plan.Metadata.IsNull() || plan.Metadata.IsUnknown() {
plan.Metadata = types.MapNull(types.StringType)
}
plan.LastUpdated = types.StringValue(time.Now().Format(time.RFC850))
plan.WorkspaceId = types.StringValue(workspaceId)
diags = resp.State.Set(ctx, plan)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
}
// Delete deletes the resource and removes the Terraform state on success.
func (r *secretResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
// Retrieve values from state
var state secretResourceModel
diags := req.State.Get(ctx, &state)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}
if r.client.Config.IsMachineIdentityAuth || r.client.Config.AuthStrategy == kmsclient.AuthStrategy.SERVICE_TOKEN {
err := r.client.DeleteRawSecretV3(kmsclient.DeleteRawSecretV3Request{
SecretName: state.Name.ValueString(),
SecretPath: state.FolderPath.ValueString(),
Environment: state.EnvSlug.ValueString(),
Type: "shared",
WorkspaceId: state.WorkspaceId.ValueString(),
})
if err != nil {
resp.Diagnostics.AddError(
"Error Deleting Kms secret",
"Could not delete secret, unexpected error: "+err.Error(),
)
return
}
} else {
resp.Diagnostics.AddError(
"Error deleting secret",
"Unknown authentication strategy",
)
return
}
}
func (r *secretResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
var workspace, environment, secretPath, secretName, secretValue, secretId string
var tags []string
var secretReminder SecretReminder
var secretMetadata []kmsclient.SecretMetadataItem
if _, err := uuid.ParseUUID(req.ID); err == nil {
secret, err := r.client.GetSingleSecretByIDV3(kmsclient.GetSingleSecretByIDV3Request{
ID: req.ID,
})
if err != nil {
if err == kmsclient.ErrNotFound {
resp.Diagnostics.AddError(
"Secret not found",
"The secret with the given ID was not found",
)
} else {
resp.Diagnostics.AddError(
"Error fetching secret",
"Couldn't fetch secret from Kms, unexpected error: "+err.Error(),
)
}
return
}
for _, tag := range secret.Secret.Tags {
tags = append(tags, tag.ID)
}
secretReminder.Note = types.StringValue(secret.Secret.SecretReminderNote)
secretReminder.RepeatDays = types.Int64Value(secret.Secret.SecretReminderRepeatDays)
workspace = secret.Secret.Workspace
environment = secret.Secret.Environment
secretPath = secret.Secret.SecretPath
secretName = secret.Secret.SecretKey
secretValue = secret.Secret.SecretValue
secretId = secret.Secret.ID
secretMetadata = secret.Secret.SecretMetadata
} else {
parts := strings.SplitN(req.ID, ":", 4)
if len(parts) != 4 {
resp.Diagnostics.AddError(
"Invalid ID Format",
"The secret ID must be a uuid or in the format of '<workspace>:<env>:<secret-path>:<secret-name>'",
)
return
}
secret, err := r.client.GetSingleRawSecretByNameV3(kmsclient.GetSingleSecretByNameV3Request{
WorkspaceId: parts[0],
Environment: parts[1],
SecretPath: parts[2],
SecretName: parts[3],
Type: "shared", // Just use the secret uuid instead if (type is 'personal')
})
if err != nil {
if err == kmsclient.ErrNotFound {
resp.Diagnostics.AddError(
"Secret not found",
"The secret with the given ID was not found",
)
} else {
resp.Diagnostics.AddError(
"Error fetching secret",
"Couldn't fetch secret from Kms, unexpected error: "+err.Error(),
)
}
return
}
for _, tag := range secret.Secret.Tags {
tags = append(tags, tag.ID)
}
secretReminder.Note = types.StringValue(secret.Secret.SecretReminderNote)
secretReminder.RepeatDays = types.Int64Value(secret.Secret.SecretReminderRepeatDays)
workspace = secret.Secret.Workspace
environment = secret.Secret.Environment
secretPath = secret.Secret.SecretPath
secretName = secret.Secret.SecretKey
secretValue = secret.Secret.SecretValue
secretId = secret.Secret.ID
secretMetadata = secret.Secret.SecretMetadata
}
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("workspace_id"), workspace)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("env_slug"), environment)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("folder_path"), secretPath)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), secretName)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("value"), secretValue)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("tag_ids"), tags)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("secret_reminder"), secretReminder)...)
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), secretId)...)
if len(secretMetadata) > 0 {
metadataMap := make(map[string]types.String, len(secretMetadata))
for _, item := range secretMetadata {
metadataMap[item.Key] = types.StringValue(item.Value)
}
metadataValue, diags := types.MapValueFrom(ctx, types.StringType, metadataMap)
resp.Diagnostics.Append(diags...)
if !resp.Diagnostics.HasError() {
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("metadata"), metadataValue)...)
}
} else {
resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("metadata"), types.MapNull(types.StringType))...)
}
}