From 4115a3d17b13e809bbe8eaad179c9efd5d573272 Mon Sep 17 00:00:00 2001 From: Dion Gionet Mallet Date: Tue, 4 Jun 2024 17:16:11 -0400 Subject: [PATCH] feat(entry_certificate): add entry_certificate resource and data source --- docs/data-sources/entry_certificate.md | 55 ++++ docs/resources/entry_certificate.md | 97 ++++++ docs/resources/vault.md | 4 +- .../dvls_entry_certificate/data-source.tf | 3 + .../dvls_entry_certificate/import.sh | 1 + .../dvls_entry_certificate/resource.tf | 31 ++ go.mod | 4 +- go.sum | 8 +- internal/provider/entry_certificate.go | 252 +++++++++++++++ .../provider/entry_certificate_data_source.go | 216 +++++++++++++ .../provider/entry_certificate_resource.go | 305 ++++++++++++++++++ internal/provider/entry_validators.go | 23 ++ internal/provider/provider.go | 2 + 13 files changed, 996 insertions(+), 5 deletions(-) create mode 100644 docs/data-sources/entry_certificate.md create mode 100644 docs/resources/entry_certificate.md create mode 100644 examples/data-sources/dvls_entry_certificate/data-source.tf create mode 100644 examples/resources/dvls_entry_certificate/import.sh create mode 100644 examples/resources/dvls_entry_certificate/resource.tf create mode 100644 internal/provider/entry_certificate.go create mode 100644 internal/provider/entry_certificate_data_source.go create mode 100644 internal/provider/entry_certificate_resource.go diff --git a/docs/data-sources/entry_certificate.md b/docs/data-sources/entry_certificate.md new file mode 100644 index 0000000..b40d2fa --- /dev/null +++ b/docs/data-sources/entry_certificate.md @@ -0,0 +1,55 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dvls_entry_certificate Data Source - terraform-provider-dvls" +subcategory: "" +description: |- + Certificate data source +--- + +# dvls_entry_certificate (Data Source) + +Certificate data source + +## Example Usage + +```terraform +data "dvls_entry_certificate" "example" { + id = "00000000-0000-0000-0000-000000000000" +} +``` + + +## Schema + +### Required + +- `id` (String) Certificate ID + +### Read-Only + +- `description` (String) Certificate description +- `expiration` (String) Certificate expiration date, in RFC3339 format (e.g. 2022-12-31T23:59:59-05:00) +- `file` (Attributes, Sensitive) Certificate file. Either file or url must be specified. (see [below for nested schema](#nestedatt--file)) +- `folder` (String) Certificate folder path +- `name` (String) Certificate name +- `password` (String, Sensitive) Certificate password +- `tags` (List of String) Certificate tags +- `url` (Attributes) Certificate url. Either file or url must be specified. (see [below for nested schema](#nestedatt--url)) +- `vault_id` (String) Vault ID + + +### Nested Schema for `file` + +Read-Only: + +- `content_b64` (String, Sensitive) Certificate base 64 encoded string +- `name` (String) Certificate file name + + + +### Nested Schema for `url` + +Read-Only: + +- `url` (String) Certificate url +- `use_default_credentials` (Boolean) Use default credentials diff --git a/docs/resources/entry_certificate.md b/docs/resources/entry_certificate.md new file mode 100644 index 0000000..caffa31 --- /dev/null +++ b/docs/resources/entry_certificate.md @@ -0,0 +1,97 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "dvls_entry_certificate Resource - terraform-provider-dvls" +subcategory: "" +description: |- + A DVLS Certificate +--- + +# dvls_entry_certificate (Resource) + +A DVLS Certificate + +## Example Usage + +```terraform +# Example with URL +resource "dvls_entry_certificate" "url" { + vault_id = "00000000-0000-0000-0000-000000000000" + name = "foo" + description = "bar" + password = "bar" + folder = "foo\\bar" + expiration = "2022-12-31T23:59:59-05:00" + tags = ["foo", "bar"] + + url = { + url = "http://foo.bar" + use_default_credentials = false + } +} + +# Example with file content +resource "dvls_entry_certificate" "file" { + vault_id = "00000000-0000-0000-0000-000000000000" + name = "foo" + description = "bar" + password = "bar" + folder = "foo\\bar" + expiration = "2022-12-31T23:59:59-05:00" + tags = ["foo", "bar"] + + file = { + name = "test.p12" + content_b64 = filebase64("test.p12") + } +} +``` + + +## Schema + +### Required + +- `expiration` (String) Certificate expiration date, in RFC3339 format (e.g. 2022-12-31T23:59:59-05:00) +- `name` (String) Certificate name +- `vault_id` (String) Vault ID + +### Optional + +- `description` (String) Certificate description +- `file` (Attributes, Sensitive) Certificate file. Either file or url must be specified. (see [below for nested schema](#nestedatt--file)) +- `folder` (String) Certificate folder path +- `password` (String, Sensitive) Certificate password +- `tags` (List of String) Certificate tags +- `url` (Attributes) Certificate url. Either file or url must be specified. (see [below for nested schema](#nestedatt--url)) + +### Read-Only + +- `id` (String) Certificate ID + + +### Nested Schema for `file` + +Required: + +- `content_b64` (String, Sensitive) Certificate base 64 encoded string +- `name` (String) Certificate file name + + + +### Nested Schema for `url` + +Required: + +- `url` (String) Certificate url + +Optional: + +- `use_default_credentials` (Boolean) Use default credentials + +## Import + +Import is supported using the following syntax: + +```shell +terraform import dvls_entry_certificate.example 00000000-0000-0000-0000-000000000000 +``` diff --git a/docs/resources/vault.md b/docs/resources/vault.md index e37bada..22e79cd 100644 --- a/docs/resources/vault.md +++ b/docs/resources/vault.md @@ -33,8 +33,8 @@ resource "dvls_vault" "example" { - `description` (String) Vault description - `master_password` (String, Sensitive) Vault master password -- `security_level` (String) Vault security level. Must be one of the following: [high, standard] -- `visibility` (String) Vault visibility. Must be one of the following: [default, public, private] +- `security_level` (String) Vault security level. Must be one of the following: [standard, high] +- `visibility` (String) Vault visibility. Must be one of the following: [private, default, public] ### Read-Only diff --git a/examples/data-sources/dvls_entry_certificate/data-source.tf b/examples/data-sources/dvls_entry_certificate/data-source.tf new file mode 100644 index 0000000..2dc5247 --- /dev/null +++ b/examples/data-sources/dvls_entry_certificate/data-source.tf @@ -0,0 +1,3 @@ +data "dvls_entry_certificate" "example" { + id = "00000000-0000-0000-0000-000000000000" +} diff --git a/examples/resources/dvls_entry_certificate/import.sh b/examples/resources/dvls_entry_certificate/import.sh new file mode 100644 index 0000000..2ade1e4 --- /dev/null +++ b/examples/resources/dvls_entry_certificate/import.sh @@ -0,0 +1 @@ +terraform import dvls_entry_certificate.example 00000000-0000-0000-0000-000000000000 diff --git a/examples/resources/dvls_entry_certificate/resource.tf b/examples/resources/dvls_entry_certificate/resource.tf new file mode 100644 index 0000000..ed58116 --- /dev/null +++ b/examples/resources/dvls_entry_certificate/resource.tf @@ -0,0 +1,31 @@ +# Example with URL +resource "dvls_entry_certificate" "url" { + vault_id = "00000000-0000-0000-0000-000000000000" + name = "foo" + description = "bar" + password = "bar" + folder = "foo\\bar" + expiration = "2022-12-31T23:59:59-05:00" + tags = ["foo", "bar"] + + url = { + url = "http://foo.bar" + use_default_credentials = false + } +} + +# Example with file content +resource "dvls_entry_certificate" "file" { + vault_id = "00000000-0000-0000-0000-000000000000" + name = "foo" + description = "bar" + password = "bar" + folder = "foo\\bar" + expiration = "2022-12-31T23:59:59-05:00" + tags = ["foo", "bar"] + + file = { + name = "test.p12" + content_b64 = filebase64("test.p12") + } +} diff --git a/go.mod b/go.mod index a858aed..237d955 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,12 @@ go 1.21 toolchain go1.22.1 require ( - github.com/Devolutions/go-dvls v0.7.0 + github.com/Devolutions/go-dvls v0.8.0 github.com/google/uuid v1.6.0 github.com/hashicorp/terraform-plugin-docs v0.19.2 github.com/hashicorp/terraform-plugin-framework v1.8.0 + github.com/hashicorp/terraform-plugin-framework-timetypes v0.4.0 + github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 github.com/hashicorp/terraform-plugin-go v0.23.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.33.0 ) diff --git a/go.sum b/go.sum index 01bdeeb..e25c84a 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/Devolutions/go-dvls v0.7.0 h1:GGA2x6THjiovSlm3vILTTO1shAJe/Dhx+MfXcyLBX+8= -github.com/Devolutions/go-dvls v0.7.0/go.mod h1:4O3lb/RK1P1cDwU5auVi7CM4gRER7EuwyLwMVuEZjgg= +github.com/Devolutions/go-dvls v0.8.0 h1:rok82K0nWhDwBGiPM/s7F84Tg8NUDihrnP9YmEvJ/wo= +github.com/Devolutions/go-dvls v0.8.0/go.mod h1:4O3lb/RK1P1cDwU5auVi7CM4gRER7EuwyLwMVuEZjgg= github.com/Kunde21/markdownfmt/v3 v3.1.0 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0= github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -103,6 +103,10 @@ github.com/hashicorp/terraform-plugin-docs v0.19.2 h1:YjdKa1vuqt9EnPYkkrv9HnGZz1 github.com/hashicorp/terraform-plugin-docs v0.19.2/go.mod h1:gad2aP6uObFKhgNE8DR9nsEuEQnibp7il0jZYYOunWY= github.com/hashicorp/terraform-plugin-framework v1.8.0 h1:P07qy8RKLcoBkCrY2RHJer5AEvJnDuXomBgou6fD8kI= github.com/hashicorp/terraform-plugin-framework v1.8.0/go.mod h1:/CpTukO88PcL/62noU7cuyaSJ4Rsim+A/pa+3rUVufY= +github.com/hashicorp/terraform-plugin-framework-timetypes v0.4.0 h1:XLI93Oqw2/KTzYjgCXrUnm8LBkGAiHC/mDQg5g5Vob4= +github.com/hashicorp/terraform-plugin-framework-timetypes v0.4.0/go.mod h1:mGuieb3bqKFYwEYB4lCMt302Z3siyv4PFYk/41wAUps= +github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 h1:HOjBuMbOEzl7snOdOoUfE2Jgeto6JOjLVQ39Ls2nksc= +github.com/hashicorp/terraform-plugin-framework-validators v0.12.0/go.mod h1:jfHGE/gzjxYz6XoUwi/aYiiKrJDeutQNUtGQXkaHklg= github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= diff --git a/internal/provider/entry_certificate.go b/internal/provider/entry_certificate.go new file mode 100644 index 0000000..c74d8c3 --- /dev/null +++ b/internal/provider/entry_certificate.go @@ -0,0 +1,252 @@ +package provider + +import ( + "context" + "encoding/base64" + "fmt" + "time" + + "github.com/Devolutions/go-dvls" + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +func newEntryCertificateFromResourceModel(plans *EntryCertificateResourceModelData) dvls.EntryCertificate { + var tags []string + + for _, v := range plans.Data.Tags { + tags = append(tags, v.ValueString()) + } + + expiration, _ := plans.Data.Expiration.ValueRFC3339Time() + + entrycertificate := dvls.EntryCertificate{ + ID: plans.Data.Id.ValueString(), + VaultId: plans.Data.VaultId.ValueString(), + Name: plans.Data.Name.ValueString(), + Description: plans.Data.Description.ValueString(), + EntryFolderPath: plans.Data.Folder.ValueString(), + Password: plans.Data.Password.ValueString(), + Expiration: expiration, + Tags: tags, + } + + if !plans.Data.File.IsNull() { + entrycertificate.CertificateIdentifier = plans.File.Name.ValueString() + } else if !plans.Data.Url.IsNull() { + entrycertificate.CertificateIdentifier = plans.Url.Url.ValueString() + entrycertificate.UseDefaultCredentials = plans.Url.UseDefaultCredentials.ValueBool() + } + + return entrycertificate +} + +func setEntryCertificateResourceModel(ctx context.Context, entrycertificate dvls.EntryCertificate, data *EntryCertificateResourceModel, content []byte) diag.Diagnostics { + var diags diag.Diagnostics + timeVal, timeDiags := timetypes.NewRFC3339Value(entrycertificate.Expiration.Format(time.RFC3339)) + diags.Append(timeDiags...) + if diags.HasError() { + return diags + } + + model := EntryCertificateResourceModel{ + Id: basetypes.NewStringValue(entrycertificate.ID), + VaultId: basetypes.NewStringValue(entrycertificate.VaultId), + Name: basetypes.NewStringValue(entrycertificate.Name), + Expiration: timeVal, + Url: basetypes.NewObjectNull(EntryCertificateResourceModelUrl{}.AttributeTypes()), + File: basetypes.NewObjectNull(EntryCertificateResourceModelFile{}.AttributeTypes()), + } + + switch entrycertificate.GetDataMode() { + case dvls.EntryCertificateDataModeFile: + fileObject := EntryCertificateResourceModelFile{ + ContentB64: basetypes.NewStringValue(base64.StdEncoding.EncodeToString(content)), + Name: basetypes.NewStringValue(entrycertificate.CertificateIdentifier), + } + + objectValue, objDiags := types.ObjectValueFrom(ctx, fileObject.AttributeTypes(), fileObject) + diags.Append(objDiags...) + if diags.HasError() { + return diags + } + + model.File = objectValue + case dvls.EntryCertificateDataModeURL: + urlObject := EntryCertificateResourceModelUrl{ + Url: basetypes.NewStringValue(entrycertificate.CertificateIdentifier), + UseDefaultCredentials: basetypes.NewBoolValue(entrycertificate.UseDefaultCredentials), + } + + objectValue, objDiags := types.ObjectValueFrom(ctx, urlObject.AttributeTypes(), urlObject) + diags.Append(objDiags...) + if diags.HasError() { + return diags + } + + model.Url = objectValue + default: + diags.AddError("unable to set certificate entry", fmt.Sprintf("unknown data mode %d. Should be 2 for files or 3 for url", entrycertificate.GetDataMode())) + } + + if entrycertificate.Password != "" { + model.Password = basetypes.NewStringValue(entrycertificate.Password) + } + + if entrycertificate.Description != "" { + model.Description = basetypes.NewStringValue(entrycertificate.Description) + } + + if entrycertificate.EntryFolderPath != "" { + model.Folder = basetypes.NewStringValue(entrycertificate.EntryFolderPath) + } + + if entrycertificate.Tags != nil { + var tagsBase []types.String + + for _, v := range entrycertificate.Tags { + tagsBase = append(tagsBase, basetypes.NewStringValue(v)) + } + + model.Tags = tagsBase + } + + *data = model + + return diags +} + +func setEntryCertificateDataModel(ctx context.Context, entrycertificate dvls.EntryCertificate, data *EntryCertificateDataSourceModel, content []byte) diag.Diagnostics { + var diags diag.Diagnostics + timeVal, timeDiags := timetypes.NewRFC3339Value(entrycertificate.Expiration.Format(time.RFC3339)) + diags.Append(timeDiags...) + if diags.HasError() { + return diags + } + + model := EntryCertificateDataSourceModel{ + Id: basetypes.NewStringValue(entrycertificate.ID), + VaultId: basetypes.NewStringValue(entrycertificate.VaultId), + Name: basetypes.NewStringValue(entrycertificate.Name), + Expiration: timeVal, + Url: basetypes.NewObjectNull(EntryCertificateResourceModelUrl{}.AttributeTypes()), + File: basetypes.NewObjectNull(EntryCertificateResourceModelFile{}.AttributeTypes()), + } + + switch entrycertificate.GetDataMode() { + case dvls.EntryCertificateDataModeFile: + fileObject := EntryCertificateResourceModelFile{ + ContentB64: basetypes.NewStringValue(base64.StdEncoding.EncodeToString(content)), + Name: basetypes.NewStringValue(entrycertificate.CertificateIdentifier), + } + + objectValue, objDiags := types.ObjectValueFrom(ctx, fileObject.AttributeTypes(), fileObject) + diags.Append(objDiags...) + if diags.HasError() { + return diags + } + + model.File = objectValue + case dvls.EntryCertificateDataModeURL: + urlObject := EntryCertificateResourceModelUrl{ + Url: basetypes.NewStringValue(entrycertificate.CertificateIdentifier), + UseDefaultCredentials: basetypes.NewBoolValue(entrycertificate.UseDefaultCredentials), + } + + objectValue, objDiags := types.ObjectValueFrom(ctx, urlObject.AttributeTypes(), urlObject) + diags.Append(objDiags...) + if diags.HasError() { + return diags + } + + model.Url = objectValue + default: + diags.AddError("unable to set certificate entry", fmt.Sprintf("unknown data mode %d. Should be 2 for files or 3 for url", entrycertificate.GetDataMode())) + } + + if entrycertificate.Password != "" { + model.Password = basetypes.NewStringValue(entrycertificate.Password) + } + + if entrycertificate.Description != "" { + model.Description = basetypes.NewStringValue(entrycertificate.Description) + } + + if entrycertificate.EntryFolderPath != "" { + model.Folder = basetypes.NewStringValue(entrycertificate.EntryFolderPath) + } + + if entrycertificate.Tags != nil { + var tagsBase []types.String + + for _, v := range entrycertificate.Tags { + tagsBase = append(tagsBase, basetypes.NewStringValue(v)) + } + + model.Tags = tagsBase + } + + *data = model + + return diags +} + +type planInterface interface { + Get(ctx context.Context, target interface{}) diag.Diagnostics +} + +func getPlans(ctx context.Context, plan planInterface) (EntryCertificateResourceModelData, diag.Diagnostics) { + var diags diag.Diagnostics + var model *EntryCertificateResourceModel + var urlPlan *EntryCertificateResourceModelUrl + var filePlan *EntryCertificateResourceModelFile + + diags.Append(plan.Get(ctx, &model)...) + if diags.HasError() { + return EntryCertificateResourceModelData{}, diags + } + + diags.Append(model.File.As(ctx, &filePlan, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return EntryCertificateResourceModelData{}, diags + } + + diags.Append(model.Url.As(ctx, &urlPlan, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return EntryCertificateResourceModelData{}, diags + } + + return EntryCertificateResourceModelData{ + Data: model, + File: filePlan, + Url: urlPlan, + }, diags +} + +func updateCertificateContent(plans EntryCertificateResourceModelData, client *dvls.Client, entrycertificate dvls.EntryCertificate, diags *diag.Diagnostics) dvls.EntryCertificate { + var err error + + if !plans.Data.File.IsNull() { + content, err := base64.StdEncoding.DecodeString(plans.File.ContentB64.ValueString()) + if err != nil { + diags.AddError("unable to update certificate entry", err.Error()) + return dvls.EntryCertificate{} + } + + entrycertificate, err = client.Entries.Certificate.NewFile(entrycertificate, content) + if err != nil { + diags.AddError("unable to update certificate entry", err.Error()) + return dvls.EntryCertificate{} + } + } else { + entrycertificate, err = client.Entries.Certificate.NewURL(entrycertificate) + if err != nil { + diags.AddError("unable to update certificate entry", err.Error()) + return dvls.EntryCertificate{} + } + } + + return entrycertificate +} diff --git a/internal/provider/entry_certificate_data_source.go b/internal/provider/entry_certificate_data_source.go new file mode 100644 index 0000000..44f0f12 --- /dev/null +++ b/internal/provider/entry_certificate_data_source.go @@ -0,0 +1,216 @@ +package provider + +import ( + "context" + "fmt" + "strings" + + "github.com/Devolutions/go-dvls" + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ datasource.DataSource = &EntryCertificateDataSource{} + +func NewEntryCertificateDataSource() datasource.DataSource { + return &EntryCertificateDataSource{} +} + +// EntryCertificateDataSource defines the data source implementation. +type EntryCertificateDataSource struct { + client *dvls.Client +} + +// EntryCertificateDataSourceModel describes the data source data model. +type EntryCertificateDataSourceModel struct { + Id types.String `tfsdk:"id"` + VaultId types.String `tfsdk:"vault_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Password types.String `tfsdk:"password"` + Folder types.String `tfsdk:"folder"` + Url types.Object `tfsdk:"url"` + File types.Object `tfsdk:"file"` + Expiration timetypes.RFC3339 `tfsdk:"expiration"` + Tags []types.String `tfsdk:"tags"` +} + +type EntryCertificateDataSourceModelData struct { + Data *EntryCertificateDataSourceModel + Url *EntryCertificateDataSourceModelUrl + File *EntryCertificateDataSourceModelFile +} + +type EntryCertificateDataSourceModelUrl struct { + Url types.String `tfsdk:"url"` + UseDefaultCredentials types.Bool `tfsdk:"use_default_credentials"` +} + +func (m EntryCertificateDataSourceModelUrl) AttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "url": types.StringType, + "use_default_credentials": types.BoolType, + } +} + +type EntryCertificateDataSourceModelFile struct { + ContentB64 types.String `tfsdk:"content_b64"` + Name types.String `tfsdk:"name"` +} + +func (m EntryCertificateDataSourceModelFile) AttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "content_b64": types.StringType, + "name": types.StringType, + } +} + +func (d *EntryCertificateDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_entry_certificate" +} + +func (d *EntryCertificateDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Certificate data source", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Certificate ID", + Required: true, + Validators: []validator.String{entryCertificateIdValidator{}}, + }, + "vault_id": schema.StringAttribute{ + Description: "Vault ID", + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "Certificate name", + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "Certificate description", + Computed: true, + }, + "password": schema.StringAttribute{ + Description: "Certificate password", + Computed: true, + Sensitive: true, + }, + "folder": schema.StringAttribute{ + Description: "Certificate folder path", + Computed: true, + }, + + "url": schema.SingleNestedAttribute{ + Description: "Certificate url. Either file or url must be specified.", + Computed: true, + + Attributes: map[string]schema.Attribute{ + "url": schema.StringAttribute{ + Description: "Certificate url", + Computed: true, + }, + "use_default_credentials": schema.BoolAttribute{ + Description: "Use default credentials", + Computed: true, + }, + }, + }, + + "file": schema.SingleNestedAttribute{ + Description: "Certificate file. Either file or url must be specified.", + Computed: true, + Sensitive: true, + + Attributes: map[string]schema.Attribute{ + "content_b64": schema.StringAttribute{ + Description: "Certificate base 64 encoded string", + Computed: true, + Sensitive: true, + }, + "name": schema.StringAttribute{ + Description: "Certificate file name", + Computed: true, + }, + }, + }, + + "expiration": schema.StringAttribute{ + CustomType: timetypes.RFC3339Type{}, + Description: "Certificate expiration date, in RFC3339 format (e.g. 2022-12-31T23:59:59-05:00)", + Computed: true, + }, + "tags": schema.ListAttribute{ + ElementType: types.StringType, + Description: "Certificate tags", + Computed: true, + }, + }, + } +} + +func (d *EntryCertificateDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*dvls.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *dvls.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + d.client = client +} + +func (d *EntryCertificateDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var data *EntryCertificateDataSourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + entrycertificateId := data.Id.ValueString() + + entrycertificate, err := d.client.Entries.Certificate.Get(entrycertificateId) + if err != nil { + if strings.Contains(err.Error(), dvls.SaveResultNotFound.String()) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("unable to read certificate entry", err.Error()) + return + } + + entrycertificate, err = d.client.Entries.Certificate.GetPassword(entrycertificate) + if err != nil { + resp.Diagnostics.AddError("unable to read certificate entry sensitive information", err.Error()) + return + } + + entryBytes, err := d.client.Entries.Certificate.GetFileContent(entrycertificate.ID) + if err != nil { + resp.Diagnostics.AddError("unable to read certificate entry content", err.Error()) + return + } + + diagsModel := setEntryCertificateDataModel(ctx, entrycertificate, data, entryBytes) + resp.Diagnostics.Append(diagsModel...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} diff --git a/internal/provider/entry_certificate_resource.go b/internal/provider/entry_certificate_resource.go new file mode 100644 index 0000000..bee37ef --- /dev/null +++ b/internal/provider/entry_certificate_resource.go @@ -0,0 +1,305 @@ +package provider + +import ( + "context" + "fmt" + "strings" + + "github.com/Devolutions/go-dvls" + "github.com/hashicorp/terraform-plugin-framework-timetypes/timetypes" + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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/objectplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &EntryCertificateResource{} +var _ resource.ResourceWithImportState = &EntryCertificateResource{} + +func NewEntryCertificateResource() resource.Resource { + return &EntryCertificateResource{} +} + +// EntryCertificateResource defines the resource implementation. +type EntryCertificateResource struct { + client *dvls.Client +} + +// EntryCertificateResourceModel describes the resource data model. +type EntryCertificateResourceModel struct { + Id types.String `tfsdk:"id"` + VaultId types.String `tfsdk:"vault_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + Password types.String `tfsdk:"password"` + Folder types.String `tfsdk:"folder"` + Url types.Object `tfsdk:"url"` + File types.Object `tfsdk:"file"` + Expiration timetypes.RFC3339 `tfsdk:"expiration"` + Tags []types.String `tfsdk:"tags"` +} + +type EntryCertificateResourceModelData struct { + Data *EntryCertificateResourceModel + Url *EntryCertificateResourceModelUrl + File *EntryCertificateResourceModelFile +} + +type EntryCertificateResourceModelUrl struct { + Url types.String `tfsdk:"url"` + UseDefaultCredentials types.Bool `tfsdk:"use_default_credentials"` +} + +func (m EntryCertificateResourceModelUrl) AttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "url": types.StringType, + "use_default_credentials": types.BoolType, + } +} + +type EntryCertificateResourceModelFile struct { + ContentB64 types.String `tfsdk:"content_b64"` + Name types.String `tfsdk:"name"` +} + +func (m EntryCertificateResourceModelFile) AttributeTypes() map[string]attr.Type { + return map[string]attr.Type{ + "content_b64": types.StringType, + "name": types.StringType, + } +} + +func (r *EntryCertificateResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_entry_certificate" +} + +func (r *EntryCertificateResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "A DVLS Certificate", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Certificate ID", + Computed: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, + }, + "vault_id": schema.StringAttribute{ + Description: "Vault ID", + Required: true, + PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, + }, + "name": schema.StringAttribute{ + Description: "Certificate name", + Required: true, + }, + "description": schema.StringAttribute{ + Description: "Certificate description", + Optional: true, + }, + "password": schema.StringAttribute{ + Description: "Certificate password", + Optional: true, + Sensitive: true, + }, + "folder": schema.StringAttribute{ + Description: "Certificate folder path", + Optional: true, + }, + + "url": schema.SingleNestedAttribute{ + Description: "Certificate url. Either file or url must be specified.", + Optional: true, + PlanModifiers: []planmodifier.Object{objectplanmodifier.RequiresReplaceIfConfigured()}, + + Attributes: map[string]schema.Attribute{ + "url": schema.StringAttribute{ + Description: "Certificate url", + Required: true, + }, + "use_default_credentials": schema.BoolAttribute{ + Description: "Use default credentials", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + }, + Validators: []validator.Object{objectvalidator.ExactlyOneOf(path.MatchRoot("file"))}, + }, + + "file": schema.SingleNestedAttribute{ + Description: "Certificate file. Either file or url must be specified.", + Optional: true, + PlanModifiers: []planmodifier.Object{objectplanmodifier.RequiresReplaceIfConfigured()}, + Sensitive: true, + + Attributes: map[string]schema.Attribute{ + "content_b64": schema.StringAttribute{ + Description: "Certificate base 64 encoded string", + Required: true, + Sensitive: true, + }, + "name": schema.StringAttribute{ + Description: "Certificate file name", + Required: true, + }, + }, + Validators: []validator.Object{objectvalidator.ExactlyOneOf(path.MatchRoot("url"))}, + }, + + "expiration": schema.StringAttribute{ + CustomType: timetypes.RFC3339Type{}, + Description: "Certificate expiration date, in RFC3339 format (e.g. 2022-12-31T23:59:59-05:00)", + Required: true, + }, + "tags": schema.ListAttribute{ + ElementType: types.StringType, + Description: "Certificate tags", + Optional: true, + }, + }, + } +} + +func (r *EntryCertificateResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*dvls.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *dvls.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +func (r *EntryCertificateResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + plans, diags := getPlans(ctx, req.Plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + entrycertificate := newEntryCertificateFromResourceModel(&plans) + + entrycertificate = updateCertificateContent(plans, r.client, entrycertificate, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + entrycertificate, err := r.client.Entries.Certificate.GetPassword(entrycertificate) + if err != nil { + resp.Diagnostics.AddError("unable to read certificate entry sensitive information", err.Error()) + return + } + + entryBytes, err := r.client.Entries.Certificate.GetFileContent(entrycertificate.ID) + if err != nil { + resp.Diagnostics.AddError("unable to read certificate entry content", err.Error()) + return + } + + diagsModel := setEntryCertificateResourceModel(ctx, entrycertificate, plans.Data, entryBytes) + resp.Diagnostics.Append(diagsModel...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plans.Data)...) +} + +func (r *EntryCertificateResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + states, diags := getPlans(ctx, req.State) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + entrycertificate := newEntryCertificateFromResourceModel(&states) + + entrycertificate, err := r.client.Entries.Certificate.Get(entrycertificate.ID) + if err != nil { + if strings.Contains(err.Error(), dvls.SaveResultNotFound.String()) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("unable to read certificate entry", err.Error()) + return + } + + entrycertificate, err = r.client.Entries.Certificate.GetPassword(entrycertificate) + if err != nil { + resp.Diagnostics.AddError("unable to read certificate entry sensitive information", err.Error()) + return + } + + entryBytes, err := r.client.Entries.Certificate.GetFileContent(entrycertificate.ID) + if err != nil { + resp.Diagnostics.AddError("unable to read certificate entry content", err.Error()) + return + } + + diagsModel := setEntryCertificateResourceModel(ctx, entrycertificate, states.Data, entryBytes) + resp.Diagnostics.Append(diagsModel...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &states.Data)...) +} + +func (r *EntryCertificateResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + plans, diags := getPlans(ctx, req.Plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + entrycertificate := newEntryCertificateFromResourceModel(&plans) + + _, err := r.client.Entries.Certificate.Update(entrycertificate) + if err != nil { + resp.Diagnostics.AddError("unable to update certificate entry", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plans.Data)...) +} + +func (r *EntryCertificateResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state *EntryCertificateResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.Entries.Certificate.Delete(state.Id.ValueString()) + if err != nil { + if strings.Contains(err.Error(), dvls.SaveResultNotFound.String()) { + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("unable to delete certificate entry", err.Error()) + return + } +} + +func (r *EntryCertificateResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/internal/provider/entry_validators.go b/internal/provider/entry_validators.go index 35e1350..349f2fa 100644 --- a/internal/provider/entry_validators.go +++ b/internal/provider/entry_validators.go @@ -8,6 +8,7 @@ import ( ) type entryusercredentialIdValidator struct{} +type entryCertificateIdValidator struct{} func (validator entryusercredentialIdValidator) Description(_ context.Context) string { return "user credential entry must be a valid UUID (ex.: 00000000-0000-0000-0000-000000000000)" @@ -30,3 +31,25 @@ func (d entryusercredentialIdValidator) ValidateString(_ context.Context, reques return } } + +func (validator entryCertificateIdValidator) Description(_ context.Context) string { + return "certificate entry must be a valid UUID (ex.: 00000000-0000-0000-0000-000000000000)" +} + +func (validator entryCertificateIdValidator) MarkdownDescription(ctx context.Context) string { + return validator.Description(ctx) +} + +func (d entryCertificateIdValidator) ValidateString(_ context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + id := request.ConfigValue.ValueString() + + _, err := uuid.Parse(id) + if err != nil { + response.Diagnostics.AddError("certificate entry id is not a valid UUID (ex.: 00000000-0000-0000-0000-000000000000)", err.Error()) + return + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index fb138ff..11f9498 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -95,6 +95,7 @@ func (p *DvlsProvider) Configure(ctx context.Context, req provider.ConfigureRequ func (p *DvlsProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ NewEntryUserCredentialResource, + NewEntryCertificateResource, NewVaultResource, } } @@ -102,6 +103,7 @@ func (p *DvlsProvider) Resources(ctx context.Context) []func() resource.Resource func (p *DvlsProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ NewEntryUserCredentialDataSource, + NewEntryCertificateDataSource, NewVaultDataSource, } }