// SPDX-License-Identifier: MPL-2.0 package local import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "io" "os" "strings" "github.com/hashicorp/terraform-plugin-framework-validators/boolvalidator" "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/booldefault" "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/stringdefault" "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-log/tflog" ) // The `var _` is a special Go construct that results in an unusable variable. // The purpose of these lines is to make sure our LocalFileResource correctly implements the `resource.Resourceā€œ interface. // These will fail at compilation time if the implementation is not satisfied. var _ resource.Resource = &LocalResource{} var _ resource.ResourceWithImportState = &LocalResource{} const unprotectedHmacSecret = "this-is-the-hmac-secret-key-that-will-be-used-to-calculate-the-hash-of-unprotected-files" // An interface for defining custom file managers. type fileClient interface { Create(directory string, name string, data string, permissions string) error // If file isn't found the error message must have err.Error() == "file not found" Read(directory string, name string) (string, string, error) // permissions, contents, error Update(currentDirectory string, currentName string, newDirectory string, newName string, data string, permissions string) error Delete(directory string, name string) error } func NewLocalResource() resource.Resource { return &LocalResource{} } type LocalResource struct { client fileClient } // LocalResourceModel describes the resource data model. type LocalResourceModel struct { Id types.String `tfsdk:"id"` Name types.String `tfsdk:"name"` Contents types.String `tfsdk:"contents"` Directory types.String `tfsdk:"directory"` Permissions types.String `tfsdk:"permissions"` HmacSecretKey types.String `tfsdk:"hmac_secret_key"` Protected types.Bool `tfsdk:"protected"` } func (r *LocalResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_local" // file_local resource } func (r *LocalResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "Local File resource", Attributes: map[string]schema.Attribute{ "name": schema.StringAttribute{ MarkdownDescription: "File name, required.", Required: true, }, "contents": schema.StringAttribute{ MarkdownDescription: "File contents, required.", Required: true, }, "directory": schema.StringAttribute{ MarkdownDescription: "The directory where the file will be placed, defaults to the current working directory.", Optional: true, Computed: true, // whenever an argument has a default value it should have Computed: true Default: stringdefault.StaticString("."), }, "permissions": schema.StringAttribute{ MarkdownDescription: "The file permissions to assign to the file, defaults to '0600'.", Optional: true, Computed: true, Default: stringdefault.StaticString("0600"), }, "hmac_secret_key": schema.StringAttribute{ MarkdownDescription: "A string used to generate the file identifier, " + "you can pass this value in the environment variable `TF_FILE_HMAC_SECRET_KEY`. " + "The provider will use a hard coded value as the secret key for unprotected files. " + "As this is used to calculate the id of the file, it can't be updated, any change will force a recreate. " + "Since this also protects delete operations, you will need to first remove the old resource from your " + "configuration with the old key, then add a new resource with the new key.", Optional: true, Computed: true, Sensitive: true, // This is for arguments that may be calculated by the provider if left empty. // It tells the Plan that this argument, if unspecified, can eventually be whatever is in state. // Modifying this is not possible as it is used to calculate the id of the file, update forces recreate. PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), stringplanmodifier.RequiresReplace(), }, }, "id": schema.StringAttribute{ MarkdownDescription: "Identifier derived from sha256+HMAC hash of file contents. " + "When setting 'protected' to true this argument is required. " + "However, when 'protected' is false then this should be left empty (computed by the provider).", Optional: true, Computed: true, }, "protected": schema.BoolAttribute{ MarkdownDescription: "Whether or not to fail update or create if the calculated id doesn't match the given id. " + "When this is true, the 'id' field is required and must match what we calculate as the hash at both create and update times. " + "If the 'id' configured doesn't match what we calculate then the provider will error rather than updating or creating the file. " + "When setting this to true, you will need to either set the `TF_FILE_HMAC_SECRET_KEY` environment variable or set the hmac_secret_key argument. ", Optional: true, Computed: true, // This tells Terraform that if this argument is changed, then we need to recreate the resource rather than updating it. // This means that if this argument is altered in the config then it won't make it to the update function. // So the plan's Protected argument must equal the state's PlanModifiers: []planmodifier.Bool{ boolplanmodifier.RequiresReplace(), }, Validators: []validator.Bool{ // This tells Terraform that if this argument is set in the plan, you must also set the 'id' argument. boolvalidator.AlsoRequires(path.Expressions{ path.MatchRoot("id"), }...), }, Default: booldefault.StaticBool(false), }, }, } } // Configure the provider for the resource if necessary. func (r *LocalResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { // Prevent panic if the provider has not been configured. if req.ProviderData == nil { return } } // We should: // - generate reality and state in the Create function // - update state to match reality in the Read function // - update state to config and update reality to config in the Update function by looking for differences in the state and the config (trust read to collect reality) // - destroy reality and state in the Destroy function func (r *LocalResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { tflog.Debug(ctx, fmt.Sprintf("Request Object: %#v", req)) var err error // Allow the ability to inject a file client, but use the osFileClient by default. // see file_os_client.go if r.client == nil { tflog.Debug(ctx, "Configuring client with default osFileClient.") r.client = &osFileClient{} } var plan LocalResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) if resp.Diagnostics.HasError() { return } id := plan.Id.ValueString() name := plan.Name.ValueString() directory := plan.Directory.ValueString() contents := plan.Contents.ValueString() permString := plan.Permissions.ValueString() hmacSecretKey := plan.HmacSecretKey.ValueString() protected := plan.Protected.ValueBool() key := hmacSecretKey if key == "" { key = os.Getenv("TF_FILE_HMAC_SECRET_KEY") if key != "" { // key was in the environment, so we want to keep the secret key empty plan.HmacSecretKey = types.StringValue("") } } if protected { err := validateProtected(protected, id, key, contents) if err != nil { resp.Diagnostics.AddError("Error creating file: ", err.Error()) return } // at this point we have an id, key, contents, protected is true, and our calculated id matches what was provided } else { id, err = calculateId(contents, unprotectedHmacSecret) if err != nil { resp.Diagnostics.AddError("Error creating file: ", "Problem calculating id from hard coded key: "+err.Error()) return } plan.Id = types.StringValue(id) // the file isn't protected so we want the key to be an empty string in state plan.HmacSecretKey = types.StringValue("") } tflog.Debug(ctx, fmt.Sprintf("Client: #%v", r.client)) if err = r.client.Create(directory, name, contents, permString); err != nil { resp.Diagnostics.AddError("Error creating file: ", err.Error()) return } resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) tflog.Debug(ctx, fmt.Sprintf("Response Object: %#v", *resp)) } // Read runs at refresh time, which happens before all other functions and every time a function would be called. // Read also runs when no other functions would be called. // After Read, if the contents of the state don't match the contents of the plan, then the resource will be reconciled. // We want to update the state to match reality so that differences can be detected. func (r *LocalResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { tflog.Debug(ctx, fmt.Sprintf("Request Object: %#v", req)) // Allow the ability to inject a file client, but use the osFileClient by default. if r.client == nil { tflog.Debug(ctx, "Configuring client with default osFileClient.") r.client = &osFileClient{} } var state LocalResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } sName := state.Name.ValueString() sDirectory := state.Directory.ValueString() sContents := state.Contents.ValueString() sPerm := state.Permissions.ValueString() sHmacSecretKey := state.HmacSecretKey.ValueString() // If Possible, we should avoid reading the file into memory // The "real" (non-calculated) parts of the file are the path, the contents, and the mode // If the file doesn't exist at the path, then we need to (re)create it perm, contents, err := r.client.Read(sDirectory, sName) if err != nil && err.Error() == "File not found." { resp.State.RemoveResource(ctx) return } if err != nil { resp.Diagnostics.AddError("Error reading file: ", err.Error()) return } if contents != sContents { // update state with actual contents state.Contents = types.StringValue(contents) // if we are updating the state contents, should we also update the state id? // state should reflect reality, but we want to make sure that protected files don't change without the correct id // we can't error here because then the user won't have the chance to update to the proper id? if sHmacSecretKey == "" { sHmacSecretKey = os.Getenv("TF_FILE_HMAC_SECRET_KEY") } id, err := calculateId(contents, sHmacSecretKey) if err != nil { resp.Diagnostics.AddError("Error reading file: ", "Problem calculating id from key: "+err.Error()) return } state.Id = types.StringValue(id) } if perm != sPerm { // update the state with the actual mode state.Permissions = types.StringValue(perm) } resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) tflog.Debug(ctx, fmt.Sprintf("Response Object: %#v", *resp)) } // For now, we are assuming Terraform has complete control over the file // This means we don't need know anything about the actual file for updates, we just change the file if the plan doesn't match the state. // The plan has the authority here, state and reality needs to match the plan. func (r *LocalResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { tflog.Debug(ctx, fmt.Sprintf("Request Object: %#v", req)) // Allow the ability to inject a file client, but use the osFileClient by default. if r.client == nil { tflog.Debug(ctx, "Configuring client with default osFileClient.") r.client = &osFileClient{} } var config LocalResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &config)...) if resp.Diagnostics.HasError() { return } cId := config.Id.ValueString() cName := config.Name.ValueString() cContents := config.Contents.ValueString() cDirectory := config.Directory.ValueString() cPerm := config.Permissions.ValueString() cHmacSecretKey := config.HmacSecretKey.ValueString() cProtected := config.Protected.ValueBool() cKey := cHmacSecretKey if cKey == "" { cKey = os.Getenv("TF_FILE_HMAC_SECRET_KEY") } if cProtected { // this only validates that the key given was correctly used to generate the id, it doesn't actually protect the file err := validateProtected(cProtected, cId, cKey, cContents) if err != nil { resp.Diagnostics.AddError("Error updating file: ", err.Error()) return } } else { id, err := calculateId(cContents, unprotectedHmacSecret) if err != nil { resp.Diagnostics.AddError("Error updating file: ", "Problem calculating id from hard coded key: "+err.Error()) return } config.Id = types.StringValue(id) config.HmacSecretKey = types.StringValue("") } // Read updates state with reality, so state = reality var reality LocalResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &reality)...) if resp.Diagnostics.HasError() { return } rId := reality.Id.ValueString() rName := reality.Name.ValueString() rContents := reality.Contents.ValueString() rDirectory := reality.Directory.ValueString() rHmacSecretKey := reality.HmacSecretKey.ValueString() rProtected := reality.Protected.ValueBool() rKey := rHmacSecretKey if rKey == "" { rKey = os.Getenv("TF_FILE_HMAC_SECRET_KEY") } if rProtected { // if the key was previously coded into the config then this only verifies that it was used to calculate the id properly // if the key is being given in the environment variable, this validates that the given key can calculate the previous id err := validateProtected(rProtected, rId, rKey, rContents) // how do I rotate keys? you can't, just remake the file, an id should be variable if err != nil { resp.Diagnostics.AddError("Error updating file: ", err.Error()) return } } else { _, err := calculateId(rContents, unprotectedHmacSecret) if err != nil { resp.Diagnostics.AddError("Error updating file: ", "Problem calculating id from hard coded key: "+err.Error()) return } } err := r.client.Update(rDirectory, rName, cDirectory, cName, cContents, cPerm) if err != nil { resp.Diagnostics.AddError("Error updating file: ", err.Error()) return } // the path, mode, and contents are all of the "real" parts of the file // the id is calculated from the secret key and contents, // so if the config's id is correct, then its key is correct // and there isn't anything to change in reality resp.Diagnostics.Append(resp.State.Set(ctx, &config)...) tflog.Debug(ctx, fmt.Sprintf("Response Object: %#v", *resp)) } func (r *LocalResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { tflog.Debug(ctx, fmt.Sprintf("Request Object: %#v", req)) // Allow the ability to inject a file client, but use the osFileClient by default. if r.client == nil { tflog.Debug(ctx, "Configuring client with default osFileClient.") r.client = &osFileClient{} } var state LocalResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &state)...) if resp.Diagnostics.HasError() { return } name := state.Name.ValueString() directory := state.Directory.ValueString() protected := state.Protected.ValueBool() id := state.Id.ValueString() key := state.HmacSecretKey.ValueString() if key == "" { key = os.Getenv("TF_FILE_HMAC_SECRET_KEY") } contents := state.Contents.ValueString() // we need to validate the id before we can delete a protected file if protected { err := validateProtected(protected, id, key, contents) if err != nil { resp.Diagnostics.AddError("Error deleting file: ", err.Error()) return } } if err := r.client.Delete(directory, name); err != nil { resp.Diagnostics.AddError("Failed to delete file: ", err.Error()) return } tflog.Debug(ctx, fmt.Sprintf("Response Object: %#v", *resp)) } func (r *LocalResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) } // **** Internal Functions **** // // generates an HMAC-SHA256 hash of a file or a string using a secret key. func calculateId(contents string, hmacSecretKey string) (string, error) { // If possible, we should avoid reading the file into memory reader := strings.NewReader(contents) hasher := hmac.New(sha256.New, []byte(hmacSecretKey)) // Copy the contents to the hasher without reading it into memory. if _, err := io.Copy(hasher, reader); err != nil { return "", fmt.Errorf("failed to copy file content to hmac hasher: %w", err) } hmacHash := hex.EncodeToString(hasher.Sum(nil)) return hmacHash, nil } func validateProtected(protected bool, id string, hmacSecretKey string, contents string) error { if !protected && id != "" { return fmt.Errorf("protected is false, but an id was provided. Either set 'protected' to 'true', or remove 'id' from configuration") } if protected && id == "" { return fmt.Errorf("protected is true, but no id was provided, please provide an 'id' when setting file to protected") } key := hmacSecretKey if protected && key == "" { return fmt.Errorf( "protected is true, but no hmac secret key was provided, " + "please provide 'hmac_secret_key' argument or set the TF_FILE_HMAC_SECRET_KEY environment variable when setting file to protected", ) } if !protected && hmacSecretKey != "" { // This error is because we will be ignoring the key if the file isn't protected // It would be pretty confusing to the user to see a hmac_secret_key that isn't being used to calculate the id. // We use hmacSecretKey here rather than 'key' because it is less confusing to the user for us to ignore the environment variable. return fmt.Errorf( "protected is false, but a hmac_secret_key was provided, " + "either set 'protected' to true or don't provide an hmac secret", ) } // if 'protected' is true, then we have an hmac secret 'key' and the user provided an 'id' if protected { calculatedId, err := calculateId(contents, key) if err != nil { return fmt.Errorf("problem calculating id from configuration: %s", err.Error()) } if id != calculatedId { return fmt.Errorf( "protected is true and a key and id were provided, but the id provided doesn't match our calculations. " + "Please try recalculating your id using a sha256 bit algorithm with the hmac secret key you provided. " + "Here is a bash line that should be equivalent: `openssl dgst -sha256 -hmac \"$TF_FILE_HMAC_SECRET_KEY\" \"$FILE_PATH\" | awk '{print $2}'`. " + "Please make sure your `TF_FILE_HMAC_SECRET_KEY` environment variable is correct if that is how you configured the key", ) } // at this point we have an id, key, contents, protected is true, and our calculated id matches what was provided } return nil }