diff --git a/go.mod b/go.mod index e7a3efb30..6357cc216 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/hashicorp/terraform-plugin-framework v1.4.1 github.com/hashicorp/terraform-plugin-framework-validators v0.12.0 github.com/hashicorp/terraform-plugin-go v0.19.0 + github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-mux v0.12.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.29.0 github.com/hashicorp/vault v1.11.3 @@ -134,7 +135,6 @@ require ( github.com/hashicorp/serf v0.9.7 // indirect github.com/hashicorp/terraform-exec v0.19.0 // indirect github.com/hashicorp/terraform-json v0.17.1 // indirect - github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect github.com/hashicorp/terraform-registry-address v0.2.2 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect diff --git a/internal/consts/consts.go b/internal/consts/consts.go index 95162bb22..a79368cd8 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -104,7 +104,6 @@ const ( FieldUsername = "username" FieldPassword = "password" FieldPasswordFile = "password_file" - FieldClientAuth = "client_auth" FieldAuthLoginGeneric = "auth_login" FieldAuthLoginUserpass = "auth_login_userpass" FieldAuthLoginAWS = "auth_login_aws" @@ -363,7 +362,6 @@ const ( FieldServiceAccountJWT = "service_account_jwt" FieldDisableISSValidation = "disable_iss_validation" FieldPEMKeys = "pem_keys" - FieldSetNamespaceFromToken = "set_namespace_from_token" /* common environment variables */ diff --git a/internal/framework/base/base.go b/internal/framework/base/base.go new file mode 100644 index 000000000..6013c54b0 --- /dev/null +++ b/internal/framework/base/base.go @@ -0,0 +1,43 @@ +package base + +import ( + "fmt" + + "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/types" + "github.com/hashicorp/terraform-provider-vault/internal/consts" + "github.com/hashicorp/terraform-provider-vault/internal/framework/validators" +) + +// BaseModel describes common fields for all of the Terraform resource data models +type BaseModel struct { + Namespace types.String `tfsdk:"namespace"` +} + +func baseSchema() map[string]schema.Attribute { + return map[string]schema.Attribute{ + consts.FieldNamespace: schema.StringAttribute{ + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + MarkdownDescription: "Target namespace. (requires Enterprise)", + Validators: []validator.String{ + validators.PathValidator(), + }, + }, + } +} + +func MustAddBaseSchema(s *schema.Schema) { + for k, v := range baseSchema() { + if _, ok := s.Attributes[k]; ok { + panic(fmt.Sprintf("cannot add schema field %q, already exists in the Schema map", k)) + } + + s.Attributes[k] = v + } +} diff --git a/internal/framework/client/client.go b/internal/framework/client/client.go new file mode 100644 index 000000000..dd17bb9c5 --- /dev/null +++ b/internal/framework/client/client.go @@ -0,0 +1,39 @@ +package client + +import ( + "context" + "fmt" + "os" + + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-provider-vault/internal/consts" + "github.com/hashicorp/terraform-provider-vault/internal/provider" + "github.com/hashicorp/vault/api" +) + +func GetClient(ctx context.Context, meta interface{}, namespace string) (*api.Client, error) { + var p *provider.ProviderMeta + + switch v := meta.(type) { + case *provider.ProviderMeta: + p = v + default: + return nil, fmt.Errorf("meta argument must be a %T, not %T", p, meta) + } + + ns := namespace + if namespace == "" { + // in order to import namespaced resources the user must provide + // the namespace from an environment variable. + ns = os.Getenv(consts.EnvVarVaultNamespaceImport) + if ns != "" { + tflog.Debug(ctx, fmt.Sprintf("Value for %q set from environment", consts.FieldNamespace)) + } + } + + if ns != "" { + return p.GetNSClient(ns) + } + + return p.GetClient() +} diff --git a/internal/framework/validators/README.md b/internal/framework/validators/README.md new file mode 100644 index 000000000..4b0a8290a --- /dev/null +++ b/internal/framework/validators/README.md @@ -0,0 +1,3 @@ +# Terraform Plugin Framework Validators + +This package contains custom Terraform Plugin Framework [validators](https://developer.hashicorp.com/terraform/plugin/framework/validation). diff --git a/internal/framework/validators/file_exists.go b/internal/framework/validators/file_exists.go new file mode 100644 index 000000000..ea16b2b0f --- /dev/null +++ b/internal/framework/validators/file_exists.go @@ -0,0 +1,62 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validators + +import ( + "context" + "fmt" + "os" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/mitchellh/go-homedir" +) + +var _ validator.String = fileExists{} + +// fileExists validates that a given token is a valid initialization token +type fileExists struct{} + +// Description describes the validation in plain text formatting. +func (v fileExists) Description(_ context.Context) string { + return "value must be a valid path to an existing file" +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v fileExists) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateString performs the validation. +func (v fileExists) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue.ValueString() + if value == "" { + response.Diagnostics.AddError("invalid file", "value cannot be empty") + return + } + + filename, err := homedir.Expand(value) + if err != nil { + response.Diagnostics.AddError("invalid file", err.Error()) + return + } + + st, err := os.Stat(filename) + if err != nil { + response.Diagnostics.AddError("invalid file", fmt.Sprintf("failed to stat path %q, err=%s", filename, err)) + return + } + + if st.IsDir() { + response.Diagnostics.AddError("invalid file", fmt.Sprintf("path %q is not a file", filename)) + return + } +} + +func FileExistsValidator() validator.String { + return fileExists{} +} diff --git a/internal/framework/validators/file_exists_test.go b/internal/framework/validators/file_exists_test.go new file mode 100644 index 000000000..434ff03c1 --- /dev/null +++ b/internal/framework/validators/file_exists_test.go @@ -0,0 +1,68 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validators + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +const testFilePath = "./testdata/fake_account.json" + +func TestFrameworkProvider_FileExistsValidator(t *testing.T) { + cases := map[string]struct { + configValue func(t *testing.T) types.String + expectedErrorCount int + }{ + "file-is-valid": { + configValue: func(t *testing.T) types.String { + return types.StringValue(testFilePath) // Path to a test fixture + }, + }, + "non-existant-file-is-not-valid": { + configValue: func(t *testing.T) types.String { + return types.StringValue("./this/path/doesnt/exist.json") // Doesn't exist + }, + expectedErrorCount: 1, + }, + "empty-string-is-not-valid": { + configValue: func(t *testing.T) types.String { + return types.StringValue("") + }, + expectedErrorCount: 1, + }, + "unconfigured-is-valid": { + configValue: func(t *testing.T) types.String { + return types.StringNull() + }, + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + // Arrange + req := validator.StringRequest{ + ConfigValue: tc.configValue(t), + } + + resp := validator.StringResponse{ + Diagnostics: diag.Diagnostics{}, + } + + f := FileExistsValidator() + + // Act + f.ValidateString(context.Background(), req, &resp) + + // Assert + if resp.Diagnostics.ErrorsCount() != tc.expectedErrorCount { + t.Errorf("Expected %d errors, got %d", tc.expectedErrorCount, resp.Diagnostics.ErrorsCount()) + } + }) + } +} diff --git a/internal/framework/validators/gcp.go b/internal/framework/validators/gcp.go new file mode 100644 index 000000000..710d01ff4 --- /dev/null +++ b/internal/framework/validators/gcp.go @@ -0,0 +1,49 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validators + +import ( + "context" + "os" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + googleoauth "golang.org/x/oauth2/google" +) + +// Credentials Validator +var _ validator.String = credentialsValidator{} + +// credentialsValidator validates that a string Attribute's is valid JSON credentials. +type credentialsValidator struct{} + +// Description describes the validation in plain text formatting. +func (v credentialsValidator) Description(_ context.Context) string { + return "value must be a path to valid JSON credentials or valid, raw, JSON credentials" +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v credentialsValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateString performs the validation. +func (v credentialsValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue.ValueString() + + // if this is a path and we can stat it, assume it's ok + if _, err := os.Stat(value); err == nil { + return + } + if _, err := googleoauth.CredentialsFromJSON(context.Background(), []byte(value)); err != nil { + response.Diagnostics.AddError("JSON credentials are not valid", err.Error()) + } +} + +func GCPCredentialsValidator() validator.String { + return credentialsValidator{} +} diff --git a/internal/framework/validators/gcp_test.go b/internal/framework/validators/gcp_test.go new file mode 100644 index 000000000..9eee65db4 --- /dev/null +++ b/internal/framework/validators/gcp_test.go @@ -0,0 +1,79 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validators + +import ( + "context" + "io/ioutil" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +const testFakeCredentialsPath = "./testdata/fake_account.json" + +func TestFrameworkProvider_CredentialsValidator(t *testing.T) { + cases := map[string]struct { + configValue func(t *testing.T) types.String + expectedErrorCount int + }{ + "configuring credentials as a path to a credentials JSON file is valid": { + configValue: func(t *testing.T) types.String { + return types.StringValue(testFakeCredentialsPath) // Path to a test fixture + }, + }, + "configuring credentials as a path to a non-existant file is NOT valid": { + configValue: func(t *testing.T) types.String { + return types.StringValue("./this/path/doesnt/exist.json") // Doesn't exist + }, + expectedErrorCount: 1, + }, + "configuring credentials as a credentials JSON string is valid": { + configValue: func(t *testing.T) types.String { + contents, err := ioutil.ReadFile(testFakeCredentialsPath) + if err != nil { + t.Fatalf("Unexpected error: %s", err) + } + stringContents := string(contents) + return types.StringValue(stringContents) + }, + }, + "configuring credentials as an empty string is not valid": { + configValue: func(t *testing.T) types.String { + return types.StringValue("") + }, + expectedErrorCount: 1, + }, + "leaving credentials unconfigured is valid": { + configValue: func(t *testing.T) types.String { + return types.StringNull() + }, + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + // Arrange + req := validator.StringRequest{ + ConfigValue: tc.configValue(t), + } + + resp := validator.StringResponse{ + Diagnostics: diag.Diagnostics{}, + } + + cv := GCPCredentialsValidator() + + // Act + cv.ValidateString(context.Background(), req, &resp) + + // Assert + if resp.Diagnostics.ErrorsCount() != tc.expectedErrorCount { + t.Errorf("Expected %d errors, got %d", tc.expectedErrorCount, resp.Diagnostics.ErrorsCount()) + } + }) + } +} diff --git a/internal/framework/validators/kerberos.go b/internal/framework/validators/kerberos.go new file mode 100644 index 000000000..820e857ba --- /dev/null +++ b/internal/framework/validators/kerberos.go @@ -0,0 +1,62 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validators + +import ( + "context" + "encoding/base64" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/jcmturner/gokrb5/v8/spnego" +) + +var _ validator.String = krbNegToken{} + +// krbNegToken validates that a given token is a valid initialization token +type krbNegToken struct{} + +// Description describes the validation in plain text formatting. +func (v krbNegToken) Description(_ context.Context) string { + return "value must be a valid initialization token" +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v krbNegToken) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateString performs the validation. +func (v krbNegToken) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue.ValueString() + if value == "" { + response.Diagnostics.AddError("invalid token", "value cannot be empty") + return + } + + b, err := base64.StdEncoding.DecodeString(value) + if err != nil { + response.Diagnostics.AddError("invalid token", "value cannot be empty") + return + } + + isNeg, _, err := spnego.UnmarshalNegToken(b) + if err != nil { + response.Diagnostics.AddError("invalid token", fmt.Sprintf("failed to unmarshal token, err=%s", err)) + return + } + + if !isNeg { + response.Diagnostics.AddError("invalid token", "not an initialization token") + return + } +} + +func KRBNegTokenValidator() validator.String { + return krbNegToken{} +} diff --git a/internal/framework/validators/kerberos_test.go b/internal/framework/validators/kerberos_test.go new file mode 100644 index 000000000..6668b12a4 --- /dev/null +++ b/internal/framework/validators/kerberos_test.go @@ -0,0 +1,75 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validators + +import ( + "context" + "encoding/base64" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +const ( + // base64 encoded SPNEGO request token + testNegTokenInit = "oIICqjCCAqagJzAlBgkqhkiG9xIBAgIGBSsFAQUCBgkqhkiC9xIBAgIGBisGAQUCBaKCAnkEggJ1YIICcQYJKoZIhvcSAQICAQBuggJgMIICXKADAgEFoQMCAQ6iBwMFAAAAAACjggFwYYIBbDCCAWigAwIBBaENGwtURVNULkdPS1JCNaIjMCGgAwIBA6EaMBgbBEhUVFAbEGhvc3QudGVzdC5nb2tyYjWjggErMIIBJ6ADAgESoQMCAQKiggEZBIIBFdS9iQq8RW9E4uei6BEb1nZ6vwMmbfzal8Ypry7ORQpa4fFF5KTRvCyEjmamxrMdl0CyawPNvSVwv88SbpCt9fXrzp4oP/UIbaR7EpsU/Aqr1NHfnB88crgMxhTfwoeDRQsse3dJZR9DK0eqov8VjABmt1fz+wDde09j1oJ2x2Nz7N0/GcZuvEOoHld/PCY7h4NW9X6NbE7M1Ye4FTjnA5LPfnP8Eqb3xTeolKe7VWbIOsTWl1eqMgpR2NaQAXrr+VKt0Yia38Mwew5s2Mm1fPhYn75SgArLZGHCVHPUn6ob3OuLzj9h2yP5zWoJ1a3OtBHhxFRrMLMzMeVw/WvFCqQDVX519IjnWXUOoDiqtkVGZ9m2T0GkgdIwgc+gAwIBEqKBxwSBxNZ7oq5M9dkXyqsdhjYFJJMg6QSCVjZi7ZJAilQ7atXt64+TdekGCiBUkd8IL9Kl/sk9+3b0EBK7YMriDwetu3ehqlbwUh824eoQ3J+3YpArJU3XZk0LzG91HyAD5BmQrxtDMNEEd7+tY4ufC3BKyAzEdzH47I2AF2K62IhLjekK2x2+f8ew/6/Tj7Xri2VHzuMNiYcygc5jrXAEKhNHixp8K93g8iOs5i27hOLQbxBw9CZfZuBUREkzXi/MTQruW/gcWZk=" + // base64 encoded response token + testNegTokenResp = "oRQwEqADCgEAoQsGCSqGSIb3EgECAg==" +) + +func TestFrameworkProvider_KRBNegTokenValidator(t *testing.T) { + cases := map[string]struct { + configValue func(t *testing.T) types.String + expectedErrorCount int + }{ + "basic": { + configValue: func(t *testing.T) types.String { + return types.StringValue(testNegTokenInit) + }, + }, + "error-b64-decoding": { + configValue: func(t *testing.T) types.String { + return types.StringValue("Negotiation foo") + }, + expectedErrorCount: 1, + }, + "error-unmarshal": { + configValue: func(t *testing.T) types.String { + return types.StringValue(base64.StdEncoding.EncodeToString([]byte(testNegTokenInit))) + }, + expectedErrorCount: 1, + }, + "error-not-init-token": { + configValue: func(t *testing.T) types.String { + return types.StringValue(testNegTokenResp) + }, + expectedErrorCount: 1, + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + // Arrange + req := validator.StringRequest{ + ConfigValue: tc.configValue(t), + } + + resp := validator.StringResponse{ + Diagnostics: diag.Diagnostics{}, + } + + k := KRBNegTokenValidator() + + // Act + k.ValidateString(context.Background(), req, &resp) + + // Assert + if resp.Diagnostics.ErrorsCount() != tc.expectedErrorCount { + t.Errorf("Expected %d errors, got %d", tc.expectedErrorCount, resp.Diagnostics.ErrorsCount()) + } + }) + } +} diff --git a/internal/framework/validators/path.go b/internal/framework/validators/path.go new file mode 100644 index 000000000..68baa0baf --- /dev/null +++ b/internal/framework/validators/path.go @@ -0,0 +1,48 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validators + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-provider-vault/internal/provider" +) + +var _ validator.String = pathValidator{} + +// pathValidator validates that a given path is a valid Vault path format +type pathValidator struct{} + +// Description describes the validation in plain text formatting. +func (v pathValidator) Description(_ context.Context) string { + return "value must be a valid Vault path format" +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v pathValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateString performs the validation. +func (v pathValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue.ValueString() + if value == "" { + response.Diagnostics.AddError("invalid Vault path", "value cannot be empty") + return + } + + if provider.RegexpPath.MatchString(value) { + response.Diagnostics.AddError("invalid Vault path", fmt.Sprintf("value %s contains leading/trailing \"/\"", value)) + } +} + +func PathValidator() validator.String { + return pathValidator{} +} diff --git a/internal/framework/validators/path_test.go b/internal/framework/validators/path_test.go new file mode 100644 index 000000000..0cd62b404 --- /dev/null +++ b/internal/framework/validators/path_test.go @@ -0,0 +1,67 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validators + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestFrameworkProvider_PathValidator(t *testing.T) { + cases := map[string]struct { + configValue func(t *testing.T) types.String + expectedErrorCount int + }{ + "valid": { + configValue: func(t *testing.T) types.String { + return types.StringValue("foo") + }, + }, + "invalid-leading": { + configValue: func(t *testing.T) types.String { + return types.StringValue("/foo") + }, + expectedErrorCount: 1, + }, + "invalid-trailing": { + configValue: func(t *testing.T) types.String { + return types.StringValue("foo/") + }, + expectedErrorCount: 1, + }, + "invalid-both": { + configValue: func(t *testing.T) types.String { + return types.StringValue("/foo/") + }, + expectedErrorCount: 1, + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + // Arrange + req := validator.StringRequest{ + ConfigValue: tc.configValue(t), + } + + resp := validator.StringResponse{ + Diagnostics: diag.Diagnostics{}, + } + + cv := PathValidator() + + // Act + cv.ValidateString(context.Background(), req, &resp) + + // Assert + if resp.Diagnostics.ErrorsCount() != tc.expectedErrorCount { + t.Errorf("Expected %d errors, got %d: %s", tc.expectedErrorCount, resp.Diagnostics.ErrorsCount(), resp.Diagnostics.Errors()) + } + }) + } +} diff --git a/internal/framework/validators/testdata/fake_account.json b/internal/framework/validators/testdata/fake_account.json new file mode 100644 index 000000000..f3362d6d2 --- /dev/null +++ b/internal/framework/validators/testdata/fake_account.json @@ -0,0 +1,7 @@ +{ + "private_key_id": "foo", + "private_key": "bar", + "client_email": "foo@bar.com", + "client_id": "id@foo.com", + "type": "service_account" +} diff --git a/internal/framework/validators/uri.go b/internal/framework/validators/uri.go new file mode 100644 index 000000000..6b3128265 --- /dev/null +++ b/internal/framework/validators/uri.go @@ -0,0 +1,77 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validators + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +var _ validator.String = uriValidator{} + +// uriValidator validates that the raw url is a valid request URI, and +// optionally contains supported scheme(s). +type uriValidator struct { + schemes []string +} + +// Description describes the validation in plain text formatting. +func (v uriValidator) Description(_ context.Context) string { + return "Invalid URI" +} + +// MarkdownDescription describes the validation in Markdown formatting. +func (v uriValidator) MarkdownDescription(ctx context.Context) string { + return v.Description(ctx) +} + +// ValidateString performs the validation. +func (v uriValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + if request.ConfigValue.IsNull() || request.ConfigValue.IsUnknown() { + return + } + + value := request.ConfigValue.ValueString() + if value == "" { + response.Diagnostics.AddError(v.Description(ctx), "value cannot be empty") + return + } + + u, err := url.ParseRequestURI(value) + if err != nil { + response.Diagnostics.AddError(v.Description(ctx), fmt.Sprintf("Failed to parse URL, err=%s", err)) + return + } + + if len(v.schemes) == 0 { + return + } + + for _, scheme := range v.schemes { + if scheme == u.Scheme { + return + } + } + + response.Diagnostics.AddError( + v.Description(ctx), + fmt.Sprintf( + "Unsupported scheme %q. Valid schemes are: %s", + u.Scheme, + strings.Join(v.schemes, ", "), + ), + ) +} + +// URIValidator validates that the raw url is a valid request URI, and +// optionally contains supported scheme(s). +func URIValidator(schemes []string) validator.String { + return uriValidator{ + schemes: schemes, + } +} diff --git a/internal/framework/validators/uri_test.go b/internal/framework/validators/uri_test.go new file mode 100644 index 000000000..e1a634e6e --- /dev/null +++ b/internal/framework/validators/uri_test.go @@ -0,0 +1,65 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validators + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +func TestFrameworkProvider_URIValidator(t *testing.T) { + cases := map[string]struct { + configValue func(t *testing.T) types.String + schemes []string + expectedErrorCount int + }{ + "basic": { + configValue: func(t *testing.T) types.String { + return types.StringValue("http://foo.baz:8080/qux") + }, + schemes: []string{"http"}, + }, + "invalid-scheme": { + configValue: func(t *testing.T) types.String { + return types.StringValue("https://foo.baz:8080/qux") + }, + schemes: []string{"http", "tcp"}, + expectedErrorCount: 1, + }, + "invalid-url": { + configValue: func(t *testing.T) types.String { + return types.StringValue("foo.bar") + }, + schemes: []string{"http", "tcp"}, + expectedErrorCount: 1, + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + // Arrange + req := validator.StringRequest{ + ConfigValue: tc.configValue(t), + } + + resp := validator.StringResponse{ + Diagnostics: diag.Diagnostics{}, + } + + cv := URIValidator(tc.schemes) + + // Act + cv.ValidateString(context.Background(), req, &resp) + + // Assert + if resp.Diagnostics.ErrorsCount() != tc.expectedErrorCount { + t.Errorf("Expected %d errors, got %d: %s", tc.expectedErrorCount, resp.Diagnostics.ErrorsCount(), resp.Diagnostics.Errors()) + } + }) + } +} diff --git a/internal/provider/auth.go b/internal/provider/auth.go index 31e5214ce..c885df664 100644 --- a/internal/provider/auth.go +++ b/internal/provider/auth.go @@ -6,6 +6,7 @@ package provider import ( "errors" "fmt" + "os" "sync" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -18,7 +19,7 @@ import ( type ( loginSchemaFunc func(string) *schema.Schema getSchemaResource func(string) *schema.Resource - validateFunc func(data *schema.ResourceData) error + validateFunc func(data *schema.ResourceData, params map[string]interface{}) error authLoginFunc func(*schema.ResourceData) (AuthLogin, error) ) @@ -138,7 +139,7 @@ func (l *AuthLoginCommon) Init(d *schema.ResourceData, authField string, validat } for _, vf := range validators { - if err := vf(d); err != nil { + if err := vf(d, params); err != nil { return err } } @@ -270,11 +271,46 @@ func (l *AuthLoginCommon) init(d *schema.ResourceData) (string, map[string]inter return path, params, nil } -func (l *AuthLoginCommon) checkRequiredFields(d *schema.ResourceData, required ...string) error { +type authDefault struct { + field string + + // envVars will override defaultVal. + // If there are multiple entries in the slice, we use the first value we + // find that is set in the environment. + envVars []string + // defaultVal is the fallback if an env var is not set + defaultVal string +} + +type authDefaults []authDefault + +func (l *AuthLoginCommon) setDefaultFields(d *schema.ResourceData, defaults authDefaults, params map[string]interface{}) error { + for _, f := range defaults { + if _, ok := l.getOk(d, f.field); !ok { + // if field is unset in the config, check env + params[f.field] = f.defaultVal + for _, envVar := range f.envVars { + val := os.Getenv(envVar) + if val != "" { + params[f.field] = val + // found a value, no need to check other options + break + } + } + } + } + + return nil +} + +func (l *AuthLoginCommon) checkRequiredFields(d *schema.ResourceData, params map[string]interface{}, required ...string) error { var missing []string for _, f := range required { - if _, ok := l.getOk(d, f); !ok { - missing = append(missing, f) + if data, ok := l.getOk(d, f); !ok { + // if the field was unset in the config + if params[f] == data { + missing = append(missing, f) + } } } diff --git a/internal/provider/auth_aws.go b/internal/provider/auth_aws.go index d8047fd26..b148460c0 100644 --- a/internal/provider/auth_aws.go +++ b/internal/provider/auth_aws.go @@ -15,6 +15,19 @@ import ( "github.com/hashicorp/terraform-provider-vault/internal/consts" ) +const ( + envVarAWSAccessKeyID = "AWS_ACCESS_KEY_ID" + envVarAWSSecretAccessKey = "AWS_SECRET_ACCESS_KEY" + envVarAWSSessionToken = "AWS_SESSION_TOKEN" + envVarAWSProfile = "AWS_PROFILE" + envVarAWSSharedCredentialsFile = "AWS_SHARED_CREDENTIALS_FILE" + envVarAWSWebIdentityTokenFile = "AWS_WEB_IDENTITY_TOKEN_FILE" + envVarAWSRoleARN = "AWS_ROLE_ARN" + envVarAWSRoleSessionName = "AWS_ROLE_SESSION_NAME" + envVarAWSRegion = "AWS_REGION" + envVarAWSDefaultRegion = "AWS_DEFAULT_REGION" +) + func init() { field := consts.FieldAuthLoginAWS if err := globalAuthLoginRegistry.Register(field, @@ -49,39 +62,33 @@ func GetAWSLoginSchemaResource(authField string) *schema.Resource { Type: schema.TypeString, Optional: true, Description: `The AWS access key ID.`, - DefaultFunc: schema.EnvDefaultFunc("AWS_ACCESS_KEY_ID", nil), }, consts.FieldAWSSecretAccessKey: { Type: schema.TypeString, Optional: true, Description: `The AWS secret access key.`, - DefaultFunc: schema.EnvDefaultFunc("AWS_SECRET_ACCESS_KEY", nil), RequiredWith: []string{fmt.Sprintf("%s.0.%s", authField, consts.FieldAWSAccessKeyID)}, }, consts.FieldAWSSessionToken: { Type: schema.TypeString, Optional: true, Description: `The AWS session token.`, - DefaultFunc: schema.EnvDefaultFunc("AWS_SESSION_TOKEN", nil), }, consts.FieldAWSProfile: { Type: schema.TypeString, Optional: true, Description: `The name of the AWS profile.`, - DefaultFunc: schema.EnvDefaultFunc("AWS_PROFILE", nil), }, consts.FieldAWSSharedCredentialsFile: { Type: schema.TypeString, Optional: true, Description: `Path to the AWS shared credentials file.`, - DefaultFunc: schema.EnvDefaultFunc("AWS_SHARED_CREDENTIALS_FILE", nil), }, consts.FieldAWSWebIdentityTokenFile: { Type: schema.TypeString, Optional: true, Description: `Path to the file containing an OAuth 2.0 access token or OpenID ` + `Connect ID token.`, - DefaultFunc: schema.EnvDefaultFunc("AWS_WEB_IDENTITY_TOKEN_FILE", nil), }, // STS assume role fields consts.FieldAWSRoleARN: { @@ -89,26 +96,17 @@ func GetAWSLoginSchemaResource(authField string) *schema.Resource { Optional: true, Description: `The ARN of the AWS Role to assume.` + `Used during STS AssumeRole`, - DefaultFunc: schema.EnvDefaultFunc("AWS_ROLE_ARN", nil), }, consts.FieldAWSRoleSessionName: { Type: schema.TypeString, Optional: true, Description: `Specifies the name to attach to the AWS role session. ` + `Used during STS AssumeRole`, - DefaultFunc: schema.EnvDefaultFunc("AWS_ROLE_SESSION_NAME", nil), }, consts.FieldAWSRegion: { Type: schema.TypeString, Optional: true, Description: `The AWS region.`, - DefaultFunc: schema.MultiEnvDefaultFunc( - []string{ - "AWS_REGION", - "AWS_DEFAULT_REGION", - }, - nil, - ), }, consts.FieldAWSSTSEndpoint: { Type: schema.TypeString, @@ -140,9 +138,13 @@ type AuthLoginAWS struct { } func (l *AuthLoginAWS) Init(d *schema.ResourceData, authField string) (AuthLogin, error) { + defaults := l.getDefaults() if err := l.AuthLoginCommon.Init(d, authField, - func(data *schema.ResourceData) error { - return l.checkRequiredFields(d, consts.FieldRole) + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.setDefaultFields(d, defaults, params) + }, + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.checkRequiredFields(d, params, consts.FieldRole) }, ); err != nil { return nil, err @@ -193,6 +195,58 @@ func (l *AuthLoginAWS) Login(client *api.Client) (*api.Secret, error) { return l.login(client, l.LoginPath(), params) } +func (l *AuthLoginAWS) getDefaults() authDefaults { + defaults := authDefaults{ + { + field: consts.FieldAWSAccessKeyID, + envVars: []string{envVarAWSAccessKeyID}, + defaultVal: "", + }, + { + field: consts.FieldAWSSecretAccessKey, + envVars: []string{envVarAWSSecretAccessKey}, + defaultVal: "", + }, + { + field: consts.FieldAWSSessionToken, + envVars: []string{envVarAWSSessionToken}, + defaultVal: "", + }, + { + field: consts.FieldAWSProfile, + envVars: []string{envVarAWSProfile}, + defaultVal: "", + }, + { + field: consts.FieldAWSSharedCredentialsFile, + envVars: []string{envVarAWSSharedCredentialsFile}, + defaultVal: "", + }, + { + field: consts.FieldAWSWebIdentityTokenFile, + envVars: []string{envVarAWSWebIdentityTokenFile}, + defaultVal: "", + }, + { + field: consts.FieldAWSRoleARN, + envVars: []string{envVarAWSRoleARN}, + defaultVal: "", + }, + { + field: consts.FieldAWSRoleSessionName, + envVars: []string{envVarAWSRoleSessionName}, + defaultVal: "", + }, + { + field: consts.FieldAWSRegion, + envVars: []string{envVarAWSRegion, envVarAWSDefaultRegion}, + defaultVal: "", + }, + } + + return defaults +} + func (l *AuthLoginAWS) getLoginData(logger hclog.Logger) (map[string]interface{}, error) { config, err := l.getCredentialsConfig(logger) if err != nil { diff --git a/internal/provider/auth_azure.go b/internal/provider/auth_azure.go index ba99d37b7..aa867b590 100644 --- a/internal/provider/auth_azure.go +++ b/internal/provider/auth_azure.go @@ -46,7 +46,6 @@ func GetAzureLoginSchemaResource(authField string) *schema.Resource { Optional: true, Description: "A signed JSON Web Token. If not specified on will be " + "created automatically", - DefaultFunc: schema.EnvDefaultFunc(consts.EnvVarAzureAuthJWT, nil), }, consts.FieldRole: { Type: schema.TypeString, @@ -94,7 +93,6 @@ func GetAzureLoginSchemaResource(authField string) *schema.Resource { consts.FieldScope: { Type: schema.TypeString, Optional: true, - Default: defaultAzureScope, Description: "The scopes to include in the token request.", ConflictsWith: []string{fmt.Sprintf("%s.0.%s", authField, consts.FieldJWT)}, }, @@ -122,12 +120,30 @@ func (l *AuthLoginAzure) LoginPath() string { } func (l *AuthLoginAzure) Init(d *schema.ResourceData, authField string) (AuthLogin, error) { + defaults := authDefaults{ + { + field: consts.FieldJWT, + envVars: []string{consts.EnvVarAzureAuthJWT}, + defaultVal: "", + }, + { + field: consts.FieldScope, + envVars: []string{""}, + defaultVal: defaultAzureScope, + }, + } if err := l.AuthLoginCommon.Init(d, authField, - func(data *schema.ResourceData) error { - err := l.checkRequiredFields(d, l.requiredParams()...) + func(data *schema.ResourceData, params map[string]interface{}) error { + err := l.setDefaultFields(d, defaults, params) + if err != nil { + return err + } + + err = l.checkRequiredFields(d, params, l.requiredParams()...) if err != nil { return err } + return l.checkFieldsOneOf(d, consts.FieldVMName, consts.FieldVMSSName) }, ); err != nil { diff --git a/internal/provider/auth_cert.go b/internal/provider/auth_cert.go index e1332e44c..cfe0bb972 100644 --- a/internal/provider/auth_cert.go +++ b/internal/provider/auth_cert.go @@ -79,8 +79,8 @@ func (l *AuthLoginCert) LoginPath() string { func (l *AuthLoginCert) Init(d *schema.ResourceData, authField string) (AuthLogin, error) { if err := l.AuthLoginCommon.Init(d, authField, - func(data *schema.ResourceData) error { - return l.checkRequiredFields(d, consts.FieldCertFile, consts.FieldKeyFile) + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.checkRequiredFields(d, params, consts.FieldCertFile, consts.FieldKeyFile) }, ); err != nil { return nil, err diff --git a/internal/provider/auth_gcp.go b/internal/provider/auth_gcp.go index 716bf17c2..397ce6532 100644 --- a/internal/provider/auth_gcp.go +++ b/internal/provider/auth_gcp.go @@ -58,7 +58,6 @@ func GetGCPLoginSchemaResource(authField string) *schema.Resource { Type: schema.TypeString, Optional: true, Description: "A signed JSON Web Token.", - DefaultFunc: schema.EnvDefaultFunc(consts.EnvVarGCPAuthJWT, nil), ConflictsWith: []string{fmt.Sprintf("%s.0.%s", authField, consts.FieldCredentials)}, }, consts.FieldCredentials: { @@ -66,7 +65,6 @@ func GetGCPLoginSchemaResource(authField string) *schema.Resource { Optional: true, ValidateFunc: validateCredentials, Description: "Path to the Google Cloud credentials file.", - DefaultFunc: schema.EnvDefaultFunc(consts.EnvVarGoogleApplicationCreds, nil), ConflictsWith: []string{fmt.Sprintf("%s.0.%s", authField, consts.FieldJWT)}, }, consts.FieldServiceAccount: { @@ -89,7 +87,26 @@ type AuthLoginGCP struct { } func (l *AuthLoginGCP) Init(d *schema.ResourceData, authField string) (AuthLogin, error) { - if err := l.AuthLoginCommon.Init(d, authField); err != nil { + defaults := authDefaults{ + { + field: consts.FieldJWT, + envVars: []string{consts.EnvVarGCPAuthJWT}, + defaultVal: "", + }, + { + field: consts.FieldCredentials, + envVars: []string{consts.EnvVarGoogleApplicationCreds}, + defaultVal: "", + }, + } + if err := l.AuthLoginCommon.Init(d, authField, + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.setDefaultFields(d, defaults, params) + }, + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.checkRequiredFields(d, params, consts.FieldRole) + }, + ); err != nil { return nil, err } return l, nil diff --git a/internal/provider/auth_jwt.go b/internal/provider/auth_jwt.go index bfa74aab5..2e080df62 100644 --- a/internal/provider/auth_jwt.go +++ b/internal/provider/auth_jwt.go @@ -42,10 +42,10 @@ func GetJWTLoginSchemaResource(authField string) *schema.Resource { Description: "Name of the login role.", }, consts.FieldJWT: { - Type: schema.TypeString, - Required: true, + Type: schema.TypeString, + // can be set via an env var + Optional: true, Description: "A signed JSON Web Token.", - DefaultFunc: schema.EnvDefaultFunc(consts.EnvVarVaultAuthJWT, nil), }, }, }, authField, consts.MountTypeJWT) @@ -71,9 +71,20 @@ func (l *AuthLoginJWT) LoginPath() string { } func (l *AuthLoginJWT) Init(d *schema.ResourceData, authField string) (AuthLogin, error) { + defaults := authDefaults{ + { + field: consts.FieldJWT, + envVars: []string{consts.EnvVarVaultAuthJWT}, + defaultVal: "", + }, + } + if err := l.AuthLoginCommon.Init(d, authField, - func(data *schema.ResourceData) error { - return l.checkRequiredFields(d, consts.FieldRole, consts.FieldJWT) + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.setDefaultFields(d, defaults, params) + }, + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.checkRequiredFields(d, params, consts.FieldRole, consts.FieldJWT) }, ); err != nil { return nil, err diff --git a/internal/provider/auth_jwt_test.go b/internal/provider/auth_jwt_test.go index 7a4c302c3..af5a5fd8d 100644 --- a/internal/provider/auth_jwt_test.go +++ b/internal/provider/auth_jwt_test.go @@ -39,6 +39,29 @@ func TestAuthLoginJWT_Init(t *testing.T) { }, wantErr: false, }, + { + name: "basic-with-env", + authField: consts.FieldAuthLoginJWT, + raw: map[string]interface{}{ + consts.FieldAuthLoginJWT: []interface{}{ + map[string]interface{}{ + consts.FieldNamespace: "ns1", + consts.FieldRole: "alice", + }, + }, + }, + envVars: map[string]string{ + consts.EnvVarVaultAuthJWT: "jwt1", + }, + expectParams: map[string]interface{}{ + consts.FieldNamespace: "ns1", + consts.FieldUseRootNamespace: false, + consts.FieldMount: consts.MountTypeJWT, + consts.FieldRole: "alice", + consts.FieldJWT: "jwt1", + }, + wantErr: false, + }, { name: "error-missing-resource", authField: consts.FieldAuthLoginJWT, diff --git a/internal/provider/auth_kerberos.go b/internal/provider/auth_kerberos.go index 9c57bd909..a0851906a 100644 --- a/internal/provider/auth_kerberos.go +++ b/internal/provider/auth_kerberos.go @@ -45,7 +45,6 @@ func GetKerberosLoginSchemaResource(authField string) *schema.Resource { consts.FieldToken: { Type: schema.TypeString, Optional: true, - DefaultFunc: schema.EnvDefaultFunc(consts.EnvVarKrbSPNEGOToken, nil), Description: "Simple and Protected GSSAPI Negotiation Mechanism (SPNEGO) token", ValidateFunc: validateKRBNegToken, }, @@ -71,7 +70,6 @@ func GetKerberosLoginSchemaResource(authField string) *schema.Resource { Type: schema.TypeString, Optional: true, Description: "A valid Kerberos configuration file e.g. /etc/krb5.conf.", - DefaultFunc: schema.EnvDefaultFunc(consts.EnvVarKRB5Conf, nil), ValidateFunc: validateFileExists, ConflictsWith: conflicts, }, @@ -79,21 +77,18 @@ func GetKerberosLoginSchemaResource(authField string) *schema.Resource { Type: schema.TypeString, Optional: true, Description: "The Kerberos keytab file containing the entry of the login entity.", - DefaultFunc: schema.EnvDefaultFunc(consts.EnvVarKRBKeytab, nil), ValidateFunc: validateFileExists, ConflictsWith: conflicts, }, consts.FieldDisableFastNegotiation: { Type: schema.TypeBool, Optional: true, - Default: false, ConflictsWith: conflicts, Description: "Disable the Kerberos FAST negotiation.", }, consts.FieldRemoveInstanceName: { Type: schema.TypeBool, Optional: true, - Default: false, ConflictsWith: conflicts, Description: "Strip the host from the username found in the keytab.", }, @@ -125,16 +120,31 @@ func (l *AuthLoginKerberos) LoginPath() string { } func (l *AuthLoginKerberos) Init(d *schema.ResourceData, authField string) (AuthLogin, error) { + defaults := authDefaults{ + { + field: consts.FieldToken, + envVars: []string{consts.EnvVarKrbSPNEGOToken}, + defaultVal: "", + }, + { + field: consts.FieldKRB5ConfPath, + envVars: []string{consts.EnvVarKRB5Conf}, + defaultVal: "", + }, + { + field: consts.FieldKeytabPath, + envVars: []string{consts.EnvVarKRBKeytab}, + defaultVal: "", + }, + } + if err := l.AuthLoginCommon.Init(d, authField, - func(data *schema.ResourceData) error { + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.setDefaultFields(d, defaults, params) + }, + func(data *schema.ResourceData, params map[string]interface{}) error { if _, ok := l.getOk(d, consts.FieldToken); !ok { - return l.checkRequiredFields(d, - consts.FieldUsername, - consts.FieldService, - consts.FieldRealm, - consts.FieldKeytabPath, - consts.FieldKRB5ConfPath, - ) + return l.checkRequiredFields(d, params, consts.FieldUsername, consts.FieldService, consts.FieldRealm, consts.FieldKeytabPath, consts.FieldKRB5ConfPath) } return nil }, diff --git a/internal/provider/auth_oci.go b/internal/provider/auth_oci.go index eb61faa93..057a61715 100644 --- a/internal/provider/auth_oci.go +++ b/internal/provider/auth_oci.go @@ -85,8 +85,8 @@ func (l *AuthLoginOCI) LoginPath() string { func (l *AuthLoginOCI) Init(d *schema.ResourceData, authField string) (AuthLogin, error) { if err := l.AuthLoginCommon.Init(d, authField, - func(data *schema.ResourceData) error { - return l.checkRequiredFields(d, consts.FieldRole, consts.FieldAuthType) + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.checkRequiredFields(d, params, consts.FieldRole, consts.FieldAuthType) }, ); err != nil { return nil, err diff --git a/internal/provider/auth_oidc.go b/internal/provider/auth_oidc.go index d7df69b5a..d92f6c469 100644 --- a/internal/provider/auth_oidc.go +++ b/internal/provider/auth_oidc.go @@ -86,8 +86,8 @@ func (l *AuthLoginOIDC) LoginPath() string { func (l *AuthLoginOIDC) Init(d *schema.ResourceData, authField string) (AuthLogin, error) { if err := l.AuthLoginCommon.Init(d, authField, - func(data *schema.ResourceData) error { - return l.checkRequiredFields(d, consts.FieldRole) + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.checkRequiredFields(d, params, consts.FieldRole) }, ); err != nil { return nil, err diff --git a/internal/provider/auth_radius.go b/internal/provider/auth_radius.go index 3b6b514e0..bf4effa6f 100644 --- a/internal/provider/auth_radius.go +++ b/internal/provider/auth_radius.go @@ -39,14 +39,14 @@ func GetRadiusLoginSchemaResource(authField string) *schema.Resource { consts.FieldUsername: { Type: schema.TypeString, Description: "The Radius username.", - Required: true, - DefaultFunc: schema.EnvDefaultFunc(consts.EnvVarRadiusUsername, nil), + // can be set via an env var + Optional: true, }, consts.FieldPassword: { - Type: schema.TypeString, - Required: true, + Type: schema.TypeString, + // can be set via an env var + Optional: true, Description: "The Radius password for username.", - DefaultFunc: schema.EnvDefaultFunc(consts.EnvVarRadiusPassword, nil), }, }, }, authField, consts.MountTypeRadius) @@ -72,9 +72,24 @@ func (l *AuthLoginRadius) LoginPath() string { } func (l *AuthLoginRadius) Init(d *schema.ResourceData, authField string) (AuthLogin, error) { + defaults := authDefaults{ + { + field: consts.FieldUsername, + envVars: []string{consts.EnvVarRadiusUsername}, + defaultVal: "", + }, + { + field: consts.FieldPassword, + envVars: []string{consts.EnvVarRadiusPassword}, + defaultVal: "", + }, + } if err := l.AuthLoginCommon.Init(d, authField, - func(data *schema.ResourceData) error { - return l.checkRequiredFields(d, consts.FieldUsername, consts.FieldPassword) + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.setDefaultFields(d, defaults, params) + }, + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.checkRequiredFields(d, params, consts.FieldUsername, consts.FieldPassword) }, ); err != nil { return nil, err diff --git a/internal/provider/auth_radius_test.go b/internal/provider/auth_radius_test.go index cf66714ab..94d2290f1 100644 --- a/internal/provider/auth_radius_test.go +++ b/internal/provider/auth_radius_test.go @@ -39,6 +39,29 @@ func TestAuthLoginRadius_Init(t *testing.T) { }, wantErr: false, }, + { + name: "basic-with-env", + authField: consts.FieldAuthLoginRadius, + raw: map[string]interface{}{ + consts.FieldAuthLoginRadius: []interface{}{ + map[string]interface{}{ + consts.FieldNamespace: "ns1", + }, + }, + }, + envVars: map[string]string{ + consts.EnvVarRadiusUsername: "alice", + consts.EnvVarRadiusPassword: "password1", + }, + expectParams: map[string]interface{}{ + consts.FieldNamespace: "ns1", + consts.FieldUseRootNamespace: false, + consts.FieldMount: consts.MountTypeRadius, + consts.FieldUsername: "alice", + consts.FieldPassword: "password1", + }, + wantErr: false, + }, { name: "error-missing-resource", authField: consts.FieldAuthLoginRadius, diff --git a/internal/provider/auth_test.go b/internal/provider/auth_test.go index fd5315a08..b73d45cdf 100644 --- a/internal/provider/auth_test.go +++ b/internal/provider/auth_test.go @@ -8,6 +8,7 @@ import ( "io" "net" "net/http" + "os" "reflect" "testing" @@ -316,3 +317,195 @@ func TestAuthLoginCommon_Namespace(t *testing.T) { }) } } + +func TestAuthLoginCommon_setDefaultFields(t *testing.T) { + tests := []struct { + name string + params map[string]interface{} + expectParams map[string]interface{} + setEnv map[string]string + defaults authDefaults + }{ + { + name: "default-unset-and-env-unset", + params: map[string]interface{}{ + "foo": "", + }, + defaults: authDefaults{ + { + field: "foo", + envVars: []string{"TEST_TERRAFORM_VAULT_PROVIDER_FOO"}, + defaultVal: "", + }, + }, + expectParams: map[string]interface{}{ + "foo": "", + }, + }, + { + name: "default-set-and-env-unset", + params: map[string]interface{}{ + "foo": "", + }, + defaults: authDefaults{ + { + field: "foo", + envVars: []string{"TEST_TERRAFORM_VAULT_PROVIDER_FOO"}, + defaultVal: "bar", + }, + }, + expectParams: map[string]interface{}{ + "foo": "bar", + }, + }, + { + name: "default-set-and-env-set", + params: map[string]interface{}{ + "foo": "", + }, + defaults: authDefaults{ + { + field: "foo", + envVars: []string{"TEST_TERRAFORM_VAULT_PROVIDER_FOO"}, + defaultVal: "bar", + }, + }, + // env vars should override authDefault.defaultVal + setEnv: map[string]string{ + "TEST_TERRAFORM_VAULT_PROVIDER_FOO": "baz", + }, + expectParams: map[string]interface{}{ + "foo": "baz", + }, + }, + { + name: "default-unset-and-env-set", + params: map[string]interface{}{ + "foo": "", + }, + defaults: authDefaults{ + { + field: "foo", + envVars: []string{"TEST_TERRAFORM_VAULT_PROVIDER_FOO"}, + defaultVal: "", + }, + }, + // env vars should override authDefault.defaultVal + setEnv: map[string]string{ + "TEST_TERRAFORM_VAULT_PROVIDER_FOO": "baz", + }, + expectParams: map[string]interface{}{ + "foo": "baz", + }, + }, + { + name: "multiple-params-default-set-and-env-set", + params: map[string]interface{}{ + "foo": "", + "dog": "", + }, + defaults: authDefaults{ + { + field: "foo", + envVars: []string{"TEST_TERRAFORM_VAULT_PROVIDER_FOO"}, + defaultVal: "bar", + }, + { + field: "dog", + envVars: []string{"TEST_TERRAFORM_VAULT_PROVIDER_DOG"}, + defaultVal: "bark", + }, + }, + // env vars should override authDefault.defaultVal + setEnv: map[string]string{ + "TEST_TERRAFORM_VAULT_PROVIDER_FOO": "baz", + "TEST_TERRAFORM_VAULT_PROVIDER_DOG": "woof", + }, + expectParams: map[string]interface{}{ + "foo": "baz", + "dog": "woof", + }, + }, + { + name: "multiple-params-mixed-set-and-unset", + params: map[string]interface{}{ + "foo": "", + "dog": "", + }, + defaults: authDefaults{ + { + field: "foo", + envVars: []string{"TEST_TERRAFORM_VAULT_PROVIDER_FOO"}, + defaultVal: "bar", + }, + { + field: "dog", + envVars: []string{"TEST_TERRAFORM_VAULT_PROVIDER_DOG"}, + defaultVal: "bark", + }, + }, + // env vars should override authDefault.defaultVal + setEnv: map[string]string{ + "TEST_TERRAFORM_VAULT_PROVIDER_FOO": "baz", + }, + expectParams: map[string]interface{}{ + "foo": "baz", + "dog": "bark", + }, + }, + { + name: "multiple-env", + params: map[string]interface{}{ + "foo": "", + }, + defaults: authDefaults{ + { + field: "foo", + envVars: []string{"TEST_TERRAFORM_VAULT_PROVIDER_FOO", "TEST_TERRAFORM_VAULT_PROVIDER_QUX"}, + defaultVal: "", + }, + }, + setEnv: map[string]string{ + "TEST_TERRAFORM_VAULT_PROVIDER_QUX": "qux", + }, + expectParams: map[string]interface{}{ + "foo": "qux", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setEnv != nil { + for k, v := range tt.setEnv { + t.Setenv(k, v) + t.Cleanup(func() { + err := os.Unsetenv(k) + if err != nil { + t.Fatalf("could not unset env, err: %v", err) + } + }, + ) + } + } + l := &AuthLoginCommon{ + params: tt.params, + initialized: true, + } + + rootProvider := NewProvider(nil, nil) + pr := &schema.Resource{ + Schema: rootProvider.Schema, + } + d := pr.TestResourceData() + + err := l.setDefaultFields(d, tt.defaults, tt.params) + if err != nil { + t.Errorf("setDefaultFields() err: %v", err) + } + + if !reflect.DeepEqual(tt.expectParams, l.Params()) { + t.Errorf("setDefaultFields() expected params %#v, actual %#v", tt.expectParams, l.Params()) + } + }) + } +} diff --git a/internal/provider/auth_token_file.go b/internal/provider/auth_token_file.go index 4c7a6ba06..592c58a48 100644 --- a/internal/provider/auth_token_file.go +++ b/internal/provider/auth_token_file.go @@ -41,9 +41,9 @@ func GetTokenFileSchemaResource(authField string) *schema.Resource { return mustAddLoginSchema(&schema.Resource{ Schema: map[string]*schema.Schema{ consts.FieldFilename: { - Type: schema.TypeString, - Required: true, - DefaultFunc: schema.EnvDefaultFunc(consts.EnvVarTokenFilename, nil), + Type: schema.TypeString, + // can be set via an env var + Optional: true, Description: "The name of a file containing a single " + "line that is a valid Vault token", }, @@ -72,9 +72,19 @@ func (l *AuthLoginTokenFile) Init(d *schema.ResourceData, ) (AuthLogin, error) { l.mount = consts.MountTypeNone + defaults := authDefaults{ + { + field: consts.FieldFilename, + envVars: []string{consts.EnvVarTokenFilename}, + defaultVal: "", + }, + } if err := l.AuthLoginCommon.Init(d, authField, - func(data *schema.ResourceData) error { - return l.checkRequiredFields(d, consts.FieldFilename) + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.setDefaultFields(d, defaults, params) + }, + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.checkRequiredFields(d, params, consts.FieldFilename) }, ); err != nil { return nil, err diff --git a/internal/provider/auth_token_file_test.go b/internal/provider/auth_token_file_test.go index 944eed7b6..61da9d129 100644 --- a/internal/provider/auth_token_file_test.go +++ b/internal/provider/auth_token_file_test.go @@ -49,9 +49,7 @@ func TestAuthLoginTokenFile_Init(t *testing.T) { consts.EnvVarTokenFilename: "/tmp/vault-token", }, expectParams: map[string]interface{}{ - consts.FieldNamespace: "", - consts.FieldUseRootNamespace: false, - consts.FieldFilename: "/tmp/vault-token", + consts.FieldFilename: "/tmp/vault-token", }, wantErr: false, }, diff --git a/internal/provider/auth_userpass.go b/internal/provider/auth_userpass.go index c7c82e427..d4e315236 100644 --- a/internal/provider/auth_userpass.go +++ b/internal/provider/auth_userpass.go @@ -40,22 +40,20 @@ func GetUserpassLoginSchemaResource(authField string) *schema.Resource { return mustAddLoginSchema(&schema.Resource{ Schema: map[string]*schema.Schema{ consts.FieldUsername: { - Type: schema.TypeString, - Required: true, + Type: schema.TypeString, + // can be set via an env var + Optional: true, Description: "Login with username", - DefaultFunc: schema.EnvDefaultFunc(consts.EnvVarUsername, nil), }, consts.FieldPassword: { Type: schema.TypeString, Optional: true, Description: "Login with password", - DefaultFunc: schema.EnvDefaultFunc(consts.EnvVarPassword, nil), }, consts.FieldPasswordFile: { Type: schema.TypeString, Optional: true, Description: "Login with password from a file", - DefaultFunc: schema.EnvDefaultFunc(consts.EnvVarPasswordFile, nil), // unfortunately the SDK does support conflicting relative fields // within a list type. As long as the top level schema does not change // we should be good to hard code fully qualified path like so. @@ -77,9 +75,30 @@ type AuthLoginUserpass struct { } func (l *AuthLoginUserpass) Init(d *schema.ResourceData, authField string) (AuthLogin, error) { + defaults := authDefaults{ + { + field: consts.FieldUsername, + envVars: []string{consts.EnvVarUsername}, + defaultVal: "", + }, + { + field: consts.FieldPassword, + envVars: []string{consts.EnvVarPassword}, + defaultVal: "", + }, + { + field: consts.FieldPasswordFile, + envVars: []string{consts.EnvVarPasswordFile}, + defaultVal: "", + }, + } + if err := l.AuthLoginCommon.Init(d, authField, - func(data *schema.ResourceData) error { - return l.checkRequiredFields(d, consts.FieldUsername) + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.setDefaultFields(d, defaults, params) + }, + func(data *schema.ResourceData, params map[string]interface{}) error { + return l.checkRequiredFields(d, params, consts.FieldUsername) }, ); err != nil { return nil, err diff --git a/internal/provider/fwprovider/auth.go b/internal/provider/fwprovider/auth.go new file mode 100644 index 000000000..a13812201 --- /dev/null +++ b/internal/provider/fwprovider/auth.go @@ -0,0 +1,61 @@ +package fwprovider + +import ( + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-validators/boolvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-provider-vault/internal/consts" + "github.com/hashicorp/terraform-provider-vault/internal/framework/validators" +) + +func mustAddLoginSchema(s *schema.ListNestedBlock, defaultMount string) schema.Block { + m := map[string]schema.Attribute{ + consts.FieldNamespace: schema.StringAttribute{ + Optional: true, + Description: fmt.Sprintf( + "The authentication engine's namespace. Conflicts with %s", + consts.FieldUseRootNamespace, + ), + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRelative().AtParent().AtName(consts.FieldUseRootNamespace), + ), + }, + }, + consts.FieldUseRootNamespace: schema.BoolAttribute{ + Optional: true, + Description: fmt.Sprintf( + "Authenticate to the root Vault namespace. Conflicts with %s", + consts.FieldNamespace, + ), + Validators: []validator.Bool{ + boolvalidator.ConflictsWith( + path.MatchRelative().AtParent().AtName(consts.FieldUseRootNamespace), + ), + }, + }, + } + if defaultMount != consts.MountTypeNone { + m[consts.FieldMount] = &schema.StringAttribute{ + Optional: true, + Description: "The path where the authentication engine is mounted.", + Validators: []validator.String{ + validators.PathValidator(), + }, + } + } + + for k, v := range m { + if _, ok := s.NestedObject.Attributes[k]; ok { + panic(fmt.Sprintf("cannot add schema field %q, already exists in the Schema map", k)) + } + + s.NestedObject.Attributes[k] = v + } + + return s +} diff --git a/internal/provider/fwprovider/auth_aws.go b/internal/provider/fwprovider/auth_aws.go new file mode 100644 index 000000000..8f5b81039 --- /dev/null +++ b/internal/provider/fwprovider/auth_aws.go @@ -0,0 +1,82 @@ +package fwprovider + +import ( + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-provider-vault/internal/consts" + "github.com/hashicorp/terraform-provider-vault/internal/framework/validators" +) + +func AuthLoginAWSSchema() schema.Block { + return mustAddLoginSchema(&schema.ListNestedBlock{ + Description: "Login to vault using the AWS method", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + consts.FieldRole: schema.StringAttribute{ + Required: true, + Description: `The Vault role to use when logging into Vault.`, + }, + // static credential fields + consts.FieldAWSAccessKeyID: schema.StringAttribute{ + Optional: true, + Description: `The AWS access key ID.`, + }, + consts.FieldAWSSecretAccessKey: schema.StringAttribute{ + Optional: true, + Description: `The AWS secret access key.`, + }, + consts.FieldAWSSessionToken: schema.StringAttribute{ + Optional: true, + Description: `The AWS session token.`, + }, + consts.FieldAWSProfile: schema.StringAttribute{ + Optional: true, + Description: `The name of the AWS profile.`, + }, + consts.FieldAWSSharedCredentialsFile: schema.StringAttribute{ + Optional: true, + Description: `Path to the AWS shared credentials file.`, + }, + consts.FieldAWSWebIdentityTokenFile: schema.StringAttribute{ + Optional: true, + Description: `Path to the file containing an OAuth 2.0 access token or OpenID ` + + `Connect ID token.`, + }, + // STS assume role fields + consts.FieldAWSRoleARN: schema.StringAttribute{ + Optional: true, + Description: `The ARN of the AWS Role to assume.` + + `Used during STS AssumeRole`, + }, + consts.FieldAWSRoleSessionName: schema.StringAttribute{ + Optional: true, + Description: `Specifies the name to attach to the AWS role session. ` + + `Used during STS AssumeRole`, + }, + consts.FieldAWSRegion: schema.StringAttribute{ + Optional: true, + Description: `The AWS region.`, + }, + consts.FieldAWSSTSEndpoint: schema.StringAttribute{ + Optional: true, + Description: `The STS endpoint URL.`, + Validators: []validator.String{ + validators.URIValidator([]string{"http", "https"}), + }, + }, + consts.FieldAWSIAMEndpoint: schema.StringAttribute{ + Optional: true, + Description: `The IAM endpoint URL.`, + Validators: []validator.String{ + validators.URIValidator([]string{"http", "https"}), + }, + }, + consts.FieldHeaderValue: schema.StringAttribute{ + Optional: true, + Description: `The Vault header value to include in the STS signing request.`, + }, + }, + }, + }, consts.MountTypeAWS) +} diff --git a/internal/provider/fwprovider/auth_azure.go b/internal/provider/fwprovider/auth_azure.go new file mode 100644 index 000000000..d94c83d9f --- /dev/null +++ b/internal/provider/fwprovider/auth_azure.go @@ -0,0 +1,82 @@ +package fwprovider + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-provider-vault/internal/consts" +) + +func AuthLoginAzureSchema() schema.Block { + return mustAddLoginSchema(&schema.ListNestedBlock{ + Description: "Login to vault using the azure method", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + consts.FieldJWT: schema.StringAttribute{ + Optional: true, + Description: "A signed JSON Web Token. If not specified on will be " + + "created automatically", + }, + consts.FieldRole: schema.StringAttribute{ + Required: true, + Description: "Name of the login role.", + }, + consts.FieldSubscriptionID: schema.StringAttribute{ + Required: true, + Description: "The subscription ID for the machine that generated the MSI token. " + + "This information can be obtained through instance metadata.", + }, + consts.FieldResourceGroupName: schema.StringAttribute{ + Required: true, + Description: "The resource group for the machine that generated the MSI token. " + + "This information can be obtained through instance metadata.", + }, + consts.FieldVMName: schema.StringAttribute{ + Optional: true, + Description: "The virtual machine name for the machine that generated the MSI token. " + + "This information can be obtained through instance metadata.", + }, + consts.FieldVMSSName: schema.StringAttribute{ + Optional: true, + Description: "The virtual machine scale set name for the machine that generated " + + "the MSI token. This information can be obtained through instance metadata.", + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldVMName), + ), + }, + }, + consts.FieldTenantID: schema.StringAttribute{ + Optional: true, + Description: "Provides the tenant ID to use in a multi-tenant " + + "authentication scenario.", + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldJWT), + ), + }, + }, + consts.FieldClientID: schema.StringAttribute{ + Optional: true, + Description: "The identity's client ID.", + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldJWT), + ), + }, + }, + consts.FieldScope: schema.StringAttribute{ + Optional: true, + Description: "The scopes to include in the token request.", + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldJWT), + ), + }, + }, + }, + }, + }, consts.MountTypeAzure) +} diff --git a/internal/provider/fwprovider/auth_cert.go b/internal/provider/fwprovider/auth_cert.go new file mode 100644 index 000000000..9cffd51a1 --- /dev/null +++ b/internal/provider/fwprovider/auth_cert.go @@ -0,0 +1,29 @@ +package fwprovider + +import ( + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + + "github.com/hashicorp/terraform-provider-vault/internal/consts" +) + +func AuthLoginCertSchema() schema.Block { + return mustAddLoginSchema(&schema.ListNestedBlock{ + Description: "Login to vault using the cert method", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + consts.FieldName: schema.StringAttribute{ + Optional: true, + Description: "Name of the certificate's role", + }, + consts.FieldCertFile: schema.StringAttribute{ + Required: true, + Description: "Path to a file containing the client certificate.", + }, + consts.FieldKeyFile: schema.StringAttribute{ + Required: true, + Description: "Path to a file containing the private key that the certificate was issued for.", + }, + }, + }, + }, consts.MountTypeCert) +} diff --git a/internal/provider/fwprovider/auth_gcp.go b/internal/provider/fwprovider/auth_gcp.go new file mode 100644 index 000000000..37d1f8a5d --- /dev/null +++ b/internal/provider/fwprovider/auth_gcp.go @@ -0,0 +1,54 @@ +package fwprovider + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-provider-vault/internal/consts" + "github.com/hashicorp/terraform-provider-vault/internal/framework/validators" +) + +func AuthLoginGCPSchema() schema.Block { + return mustAddLoginSchema(&schema.ListNestedBlock{ + Description: "Login to vault using the gcp method", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + consts.FieldRole: schema.StringAttribute{ + Required: true, + Description: "Name of the login role.", + }, + consts.FieldJWT: schema.StringAttribute{ + Optional: true, + Description: "A signed JSON Web Token.", + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldCredentials), + ), + }, + }, + consts.FieldCredentials: schema.StringAttribute{ + Optional: true, + Description: "Path to the Google Cloud credentials file.", + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldJWT), + ), + stringvalidator.LengthAtLeast(1), + validators.GCPCredentialsValidator(), + }, + }, + consts.FieldServiceAccount: schema.StringAttribute{ + Optional: true, + Description: "IAM service account.", + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldJWT), + ), + }, + }, + }, + }, + }, consts.MountTypeGCP) +} diff --git a/internal/provider/fwprovider/auth_generic.go b/internal/provider/fwprovider/auth_generic.go new file mode 100644 index 000000000..a5e04e117 --- /dev/null +++ b/internal/provider/fwprovider/auth_generic.go @@ -0,0 +1,29 @@ +package fwprovider + +import ( + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-provider-vault/internal/consts" +) + +func AuthLoginGenericSchema() schema.Block { + return mustAddLoginSchema(&schema.ListNestedBlock{ + Description: "Login to vault with an existing auth method using auth//login", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + consts.FieldPath: schema.StringAttribute{ + Required: true, + }, + consts.FieldParameters: schema.MapAttribute{ + Optional: true, + ElementType: types.StringType, + Sensitive: true, + }, + consts.FieldMethod: schema.StringAttribute{ + Optional: true, + }, + }, + }, + }, consts.MountTypeNone) +} diff --git a/internal/provider/fwprovider/auth_jwt.go b/internal/provider/fwprovider/auth_jwt.go new file mode 100644 index 000000000..d8527577f --- /dev/null +++ b/internal/provider/fwprovider/auth_jwt.go @@ -0,0 +1,26 @@ +package fwprovider + +import ( + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + + "github.com/hashicorp/terraform-provider-vault/internal/consts" +) + +func AuthLoginJWTSchema() schema.Block { + return mustAddLoginSchema(&schema.ListNestedBlock{ + Description: "Login to vault using the jwt method", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + consts.FieldRole: schema.StringAttribute{ + Required: true, + Description: "Name of the login role.", + }, + consts.FieldJWT: schema.StringAttribute{ + // can be set via an env var + Optional: true, + Description: "A signed JSON Web Token.", + }, + }, + }, + }, consts.MountTypeJWT) +} diff --git a/internal/provider/fwprovider/auth_kerberos.go b/internal/provider/fwprovider/auth_kerberos.go new file mode 100644 index 000000000..9f591866c --- /dev/null +++ b/internal/provider/fwprovider/auth_kerberos.go @@ -0,0 +1,94 @@ +package fwprovider + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/boolvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-provider-vault/internal/consts" + "github.com/hashicorp/terraform-provider-vault/internal/framework/validators" +) + +func AuthLoginKerberosSchema() schema.Block { + return mustAddLoginSchema(&schema.ListNestedBlock{ + Description: "Login to vault using the kerberos method", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + consts.FieldToken: schema.StringAttribute{ + Optional: true, + Description: "Simple and Protected GSSAPI Negotiation Mechanism (SPNEGO) token", + Validators: []validator.String{ + validators.KRBNegTokenValidator(), + }, + }, + consts.FieldUsername: schema.StringAttribute{ + Optional: true, + Description: "The username to login into Kerberos with.", + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldToken), + ), + }, + }, + consts.FieldService: schema.StringAttribute{ + Optional: true, + Description: "The service principle name.", + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldToken), + ), + }, + }, + consts.FieldRealm: schema.StringAttribute{ + Optional: true, + Description: "The Kerberos server's authoritative authentication domain", + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldToken), + ), + }, + }, + consts.FieldKRB5ConfPath: schema.StringAttribute{ + Optional: true, + Description: "A valid Kerberos configuration file e.g. /etc/krb5.conf.", + Validators: []validator.String{ + validators.FileExistsValidator(), + stringvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldToken), + ), + }, + }, + consts.FieldKeytabPath: schema.StringAttribute{ + Optional: true, + Description: "The Kerberos keytab file containing the entry of the login entity.", + Validators: []validator.String{ + validators.FileExistsValidator(), + stringvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldToken), + ), + }, + }, + consts.FieldDisableFastNegotiation: schema.BoolAttribute{ + Optional: true, + Validators: []validator.Bool{ + boolvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldToken), + ), + }, + Description: "Disable the Kerberos FAST negotiation.", + }, + consts.FieldRemoveInstanceName: schema.BoolAttribute{ + Optional: true, + Validators: []validator.Bool{ + boolvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldToken), + ), + }, + Description: "Strip the host from the username found in the keytab.", + }, + }, + }, + }, consts.MountTypeKerberos) +} diff --git a/internal/provider/fwprovider/auth_oci.go b/internal/provider/fwprovider/auth_oci.go new file mode 100644 index 000000000..bbd640dc0 --- /dev/null +++ b/internal/provider/fwprovider/auth_oci.go @@ -0,0 +1,35 @@ +package fwprovider + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-provider-vault/internal/consts" +) + +const ( + ociAuthTypeInstance = "instance" + ociAuthTypeAPIKeys = "apikey" +) + +func AuthLoginOCISchema() schema.Block { + return mustAddLoginSchema(&schema.ListNestedBlock{ + Description: "Login to vault using the OCI method", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + consts.FieldRole: schema.StringAttribute{ + Required: true, + Description: "Name of the login role.", + }, + consts.FieldAuthType: schema.StringAttribute{ + Required: true, + Description: "Authentication type to use when getting OCI credentials.", + Validators: []validator.String{ + stringvalidator.OneOf([]string{ociAuthTypeInstance, ociAuthTypeAPIKeys}...), + }, + }, + }, + }, + }, consts.MountTypeOCI) +} diff --git a/internal/provider/fwprovider/auth_oidc.go b/internal/provider/fwprovider/auth_oidc.go new file mode 100644 index 000000000..5a90185dd --- /dev/null +++ b/internal/provider/fwprovider/auth_oidc.go @@ -0,0 +1,38 @@ +package fwprovider + +import ( + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-provider-vault/internal/consts" + "github.com/hashicorp/terraform-provider-vault/internal/framework/validators" +) + +func AuthLoginOIDCSchema() schema.Block { + return mustAddLoginSchema(&schema.ListNestedBlock{ + Description: "Login to vault using the oidc method", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + consts.FieldRole: schema.StringAttribute{ + Required: true, + Description: "Name of the login role.", + }, + + consts.FieldCallbackListenerAddress: schema.StringAttribute{ + Optional: true, + Description: "The callback listener's address. Must be a valid URI without the path.", + Validators: []validator.String{ + validators.URIValidator([]string{"tcp"}), + }, + }, + consts.FieldCallbackAddress: schema.StringAttribute{ + Optional: true, + Description: "The callback address. Must be a valid URI without the path.", + Validators: []validator.String{ + validators.URIValidator([]string{"http", "https"}), + }, + }, + }, + }, + }, consts.MountTypeOIDC) +} diff --git a/internal/provider/fwprovider/auth_radius.go b/internal/provider/fwprovider/auth_radius.go new file mode 100644 index 000000000..d18b52442 --- /dev/null +++ b/internal/provider/fwprovider/auth_radius.go @@ -0,0 +1,27 @@ +package fwprovider + +import ( + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + + "github.com/hashicorp/terraform-provider-vault/internal/consts" +) + +func AuthLoginRadiusSchema() schema.Block { + return mustAddLoginSchema(&schema.ListNestedBlock{ + Description: "Login to vault using the radius method", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + consts.FieldUsername: schema.StringAttribute{ + Description: "The Radius username.", + // can be set via an env var + Optional: true, + }, + consts.FieldPassword: schema.StringAttribute{ + // can be set via an env var + Optional: true, + Description: "The Radius password for username.", + }, + }, + }, + }, consts.MountTypeRadius) +} diff --git a/internal/provider/fwprovider/auth_token_file.go b/internal/provider/fwprovider/auth_token_file.go new file mode 100644 index 000000000..9ea611b48 --- /dev/null +++ b/internal/provider/fwprovider/auth_token_file.go @@ -0,0 +1,22 @@ +package fwprovider + +import ( + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-provider-vault/internal/consts" +) + +func AuthLoginTokenFileSchema() schema.Block { + return mustAddLoginSchema(&schema.ListNestedBlock{ + Description: "Login to vault using ", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + consts.FieldFilename: schema.StringAttribute{ + // can be set via an env var + Optional: true, + Description: "The name of a file containing a single " + + "line that is a valid Vault token", + }, + }, + }, + }, consts.MountTypeNone) +} diff --git a/internal/provider/fwprovider/auth_userpass.go b/internal/provider/fwprovider/auth_userpass.go new file mode 100644 index 000000000..3f810d755 --- /dev/null +++ b/internal/provider/fwprovider/auth_userpass.go @@ -0,0 +1,38 @@ +package fwprovider + +import ( + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/provider/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + + "github.com/hashicorp/terraform-provider-vault/internal/consts" +) + +func AuthLoginUserpassSchema() schema.Block { + return mustAddLoginSchema(&schema.ListNestedBlock{ + Description: "Login to vault using the userpass method", + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + consts.FieldUsername: schema.StringAttribute{ + // can be set via an env var + Optional: true, + Description: "Login with username", + }, + consts.FieldPassword: schema.StringAttribute{ + Optional: true, + Description: "Login with password", + }, + consts.FieldPasswordFile: schema.StringAttribute{ + Optional: true, + Description: "Login with password from a file", + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRelative().AtName(consts.FieldPassword), + ), + }, + }, + }, + }, + }, consts.MountTypeUserpass) +} diff --git a/internal/provider/fwprovider/provider.go b/internal/provider/fwprovider/provider.go index c5b91a334..16a910b2b 100644 --- a/internal/provider/fwprovider/provider.go +++ b/internal/provider/fwprovider/provider.go @@ -10,7 +10,9 @@ import ( "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-provider-vault/internal/consts" + "github.com/hashicorp/terraform-provider-vault/internal/sys" ) // Ensure the implementation satisfies the provider.Provider interface @@ -142,6 +144,18 @@ func (p *fwprovider) Schema(ctx context.Context, req provider.SchemaRequest, res }, }, }, + consts.FieldAuthLoginAWS: AuthLoginAWSSchema(), + consts.FieldAuthLoginAzure: AuthLoginAzureSchema(), + consts.FieldAuthLoginCert: AuthLoginCertSchema(), + consts.FieldAuthLoginGCP: AuthLoginGCPSchema(), + consts.FieldAuthLoginGeneric: AuthLoginGenericSchema(), + consts.FieldAuthLoginJWT: AuthLoginJWTSchema(), + consts.FieldAuthLoginKerberos: AuthLoginKerberosSchema(), + consts.FieldAuthLoginOCI: AuthLoginOCISchema(), + consts.FieldAuthLoginOIDC: AuthLoginOIDCSchema(), + consts.FieldAuthLoginRadius: AuthLoginRadiusSchema(), + consts.FieldAuthLoginTokenFile: AuthLoginTokenFileSchema(), + consts.FieldAuthLoginUserpass: AuthLoginUserpassSchema(), }, } } @@ -165,7 +179,9 @@ func (p *fwprovider) Configure(ctx context.Context, req provider.ConfigureReques // The resource type name is determined by the Resource implementing // the Metadata method. All resources must have unique names. func (p *fwprovider) Resources(ctx context.Context) []func() resource.Resource { - return []func() resource.Resource{} + return []func() resource.Resource{ + sys.NewPasswordPolicyResource, + } } // DataSources returns a slice of functions to instantiate each DataSource diff --git a/internal/provider/meta.go b/internal/provider/meta.go index d58407ce7..28d4ece2a 100644 --- a/internal/provider/meta.go +++ b/internal/provider/meta.go @@ -188,7 +188,7 @@ func (p *ProviderMeta) setClient() error { addr := GetResourceDataStr(d, consts.FieldAddress, api.EnvVaultAddress, "") if addr == "" { - return nil, fmt.Errorf("failed to configure Vault address") + return fmt.Errorf("failed to configure Vault address") } clientConfig.Address = addr @@ -340,12 +340,20 @@ func (p *ProviderMeta) setClient() error { "Future releases may not support this type of configuration.", tokenNamespace) namespace = tokenNamespace + // set the namespace on the provider to ensure that all child // namespace paths are properly honoured. - if v, ok := d.Get(consts.FieldSetNamespaceFromToken).(bool); ok && v { + // We default to setting the namespace from the token unless the + // env var is set to false. + setFromToken, err := strconv.ParseBool(os.Getenv("VAULT_SET_NAMESPACE_FROM_TOKEN")) + if err == nil && setFromToken || err != nil { if err := d.Set(consts.FieldNamespace, namespace); err != nil { return err } + } else { + log.Printf("[WARN] VAULT_SET_NAMESPACE_FROM_TOKEN environment "+ + "variable is set to \"false\". The token namespace %q will "+ + "not be used as the root namespace for all resources.", tokenNamespace) } } diff --git a/internal/provider/meta_test.go b/internal/provider/meta_test.go index 983fa3811..d699c172a 100644 --- a/internal/provider/meta_test.go +++ b/internal/provider/meta_test.go @@ -536,7 +536,7 @@ func TestNewProviderMeta(t *testing.T) { testutil.SkipTestAccEnt(t) testutil.TestAccPreCheck(t) - nsPrefix := acctest.RandomWithPrefix("ns") + nsPrefix := acctest.RandomWithPrefix("ns") + "-" defaultUser := "alice" defaultPassword := "f00bazB1ff" @@ -550,6 +550,7 @@ func TestNewProviderMeta(t *testing.T) { name string d *schema.ResourceData data map[string]interface{} + env map[string]string wantNamespace string tokenNamespace string authLoginNamespace string @@ -644,9 +645,11 @@ func TestNewProviderMeta(t *testing.T) { name: "set-namespace-from-token-false", d: pr.TestResourceData(), data: map[string]interface{}{ - consts.FieldSkipGetVaultVersion: true, - consts.FieldSetNamespaceFromToken: false, - consts.FieldSkipChildToken: true, + consts.FieldSkipGetVaultVersion: true, + consts.FieldSkipChildToken: true, + }, + env: map[string]string{ + "VAULT_SET_NAMESPACE_FROM_TOKEN": "false", }, tokenNamespace: nsPrefix + "set-ns-from-token-auth-false-ignored", wantNamespace: nsPrefix + "set-ns-from-token-auth-false-ignored", @@ -659,9 +662,8 @@ func TestNewProviderMeta(t *testing.T) { name: "set-namespace-from-token-true", d: pr.TestResourceData(), data: map[string]interface{}{ - consts.FieldSkipGetVaultVersion: true, - consts.FieldSetNamespaceFromToken: true, - consts.FieldSkipChildToken: true, + consts.FieldSkipGetVaultVersion: true, + consts.FieldSkipChildToken: true, consts.FieldAuthLoginUserpass: []map[string]interface{}{ { consts.FieldNamespace: nsPrefix + "set-ns-from-token-auth-true", @@ -671,12 +673,32 @@ func TestNewProviderMeta(t *testing.T) { }, }, }, + env: map[string]string{ + "VAULT_SET_NAMESPACE_FROM_TOKEN": "true", + }, authLoginNamespace: nsPrefix + "set-ns-from-token-auth-true", wantNamespace: nsPrefix + "set-ns-from-token-auth-true", checkSetSetTokenNamespace: true, wantNamespaceFromToken: nsPrefix + "set-ns-from-token-auth-true", wantErr: false, }, + { + // expect token namespace to be ignored + name: "with-token-ns-only-override-env-false", + d: pr.TestResourceData(), + data: map[string]interface{}{ + consts.FieldSkipGetVaultVersion: true, + consts.FieldSkipChildToken: true, + }, + env: map[string]string{ + "VAULT_SET_NAMESPACE_FROM_TOKEN": "false", + }, + tokenNamespace: nsPrefix + "token-ns-only", + wantNamespace: nsPrefix + "token-ns-only", + checkSetSetTokenNamespace: true, + wantNamespaceFromToken: "", + wantErr: false, + }, } createNamespace := func(t *testing.T, client *api.Client, ns string) { @@ -704,9 +726,20 @@ func TestNewProviderMeta(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { - t.Parallel() + // we cannot run these in Parallel + // some of the test cases will set env vars that will cause flakiness + if tt.env != nil { + for k, v := range tt.env { + if err := os.Setenv(k, v); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + os.Unsetenv(k) + }) + } + } + if tt.authLoginNamespace != "" { createNamespace(t, client, tt.authLoginNamespace) options := &api.EnableAuthOptions{ @@ -806,7 +839,7 @@ func TestNewProviderMeta_Cert(t *testing.T) { testutil.SkipTestAccEnt(t) testutil.TestAccPreCheck(t) - nsPrefix := acctest.RandomWithPrefix("ns") + nsPrefix := acctest.RandomWithPrefix("ns") + "-" defaultUser := "alice" defaultPassword := "f00bazB1ff" diff --git a/internal/provider/provider.go b/internal/provider/provider.go index c2db09303..07fd0a294 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -138,14 +138,6 @@ func NewProvider( Optional: true, Description: "The namespace to use. Available only for Vault Enterprise.", }, - consts.FieldSetNamespaceFromToken: { - Type: schema.TypeBool, - Optional: true, - Default: true, - Description: "In the case where the Vault token is for a specific namespace " + - "and the provider namespace is not configured, use the token namespace " + - "as the root namespace for all resources.", - }, "headers": { Type: schema.TypeList, Optional: true, diff --git a/internal/provider/validators.go b/internal/provider/validators.go index 15e23212c..986562433 100644 --- a/internal/provider/validators.go +++ b/internal/provider/validators.go @@ -22,7 +22,7 @@ import ( var ( regexpPathLeading = regexp.MustCompile(fmt.Sprintf(`^%s`, consts.PathDelim)) regexpPathTrailing = regexp.MustCompile(fmt.Sprintf(`%s$`, consts.PathDelim)) - regexpPath = regexp.MustCompile(fmt.Sprintf(`%s|%s`, regexpPathLeading, regexpPathTrailing)) + RegexpPath = regexp.MustCompile(fmt.Sprintf(`%s|%s`, regexpPathLeading, regexpPathTrailing)) regexpUUID = regexp.MustCompile("^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$") ) @@ -63,7 +63,7 @@ func ValidateNoTrailingSlash(i interface{}, k string) ([]string, []error) { func ValidateNoLeadingTrailingSlashes(i interface{}, k string) ([]string, []error) { var errs []error - if err := validatePath(regexpPath, i, k); err != nil { + if err := validatePath(RegexpPath, i, k); err != nil { errs = append(errs, err) } @@ -71,7 +71,7 @@ func ValidateNoLeadingTrailingSlashes(i interface{}, k string) ([]string, []erro } func ValidateDiagPath(i interface{}, path cty.Path) diag.Diagnostics { - return validateDiagPath(regexpPath, i, path) + return validateDiagPath(RegexpPath, i, path) } func validateDiagPath(r *regexp.Regexp, i interface{}, path cty.Path) diag.Diagnostics { diff --git a/internal/sys/password_policy.go b/internal/sys/password_policy.go new file mode 100644 index 000000000..8232427ce --- /dev/null +++ b/internal/sys/password_policy.go @@ -0,0 +1,256 @@ +package sys + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" + + "github.com/hashicorp/terraform-provider-vault/internal/framework/base" + "github.com/hashicorp/terraform-provider-vault/internal/framework/client" + "github.com/hashicorp/terraform-provider-vault/internal/provider" +) + +// ensure we our interface implementation is correct +var _ resource.ResourceWithConfigure = &PasswordPolicyResource{} + +func NewPasswordPolicyResource() resource.Resource { + return &PasswordPolicyResource{} +} + +type PasswordPolicyResource struct { + meta *provider.ProviderMeta +} + +func (r *PasswordPolicyResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_password_policy_fw" +} + +// PasswordPolicyModel describes the Terraform resource data model to match the +// resource schema. +type PasswordPolicyModel struct { + Namespace types.String `tfsdk:"namespace"` + + Name types.String `tfsdk:"name"` + Policy types.String `tfsdk:"policy"` +} + +// PasswordPolicyResourceAPIModel describes the Vault API data model. +type PasswordPolicyResourceAPIModel struct { + Policy string `json:"policy" mapstructure:"policy"` +} + +func (r *PasswordPolicyResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + meta, ok := req.ProviderData.(*provider.ProviderMeta) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *provider.ProviderMeta, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + r.meta = meta +} + +func (r *PasswordPolicyResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + MarkdownDescription: "Name of the password policy.", + Required: true, + }, + "policy": schema.StringAttribute{ + Required: true, + MarkdownDescription: "The password policy document", + }, + }, + MarkdownDescription: "Provides a resource to manage Password Policies.", + } + + base.MustAddBaseSchema(&resp.Schema) +} + +func (r *PasswordPolicyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan PasswordPolicyModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + client, err := client.GetClient(ctx, r.meta, plan.Namespace.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error Configuring Resource Client", err.Error()) + return + } + + data := map[string]interface{}{ + "policy": plan.Policy.ValueString(), + } + path := r.path(plan.Name.ValueString()) + // vault returns a nil response on success + _, err = client.Logical().Write(path, data) + if err != nil { + resp.Diagnostics.AddError( + "Unable to Create Resource", + "An unexpected error occurred while attempting to create the resource. "+ + "Please retry the operation or report this issue to the provider developers.\n\n"+ + "HTTP Error: "+err.Error(), + ) + + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *PasswordPolicyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state PasswordPolicyModel + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + client, err := client.GetClient(ctx, r.meta, state.Namespace.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error Configuring Resource Client", err.Error()) + return + } + + // TODO: refactor the following read, marshal and unmarshal into a helper? + path := r.path(state.Name.ValueString()) + policyResp, err := client.Logical().Read(path) + if err != nil { + resp.Diagnostics.AddError( + "Unable to Read Resource from Vault", + "An unexpected error occurred while attempting to read the resource. "+ + "Please retry the operation or report this issue to the provider developers.\n\n"+ + "HTTP Error: "+err.Error(), + ) + + return + } + if policyResp == nil { + resp.Diagnostics.AddError( + "Unable to Read Resource from Vault", + "An unexpected error occurred while attempting to read the resource. "+ + "Please retry the operation or report this issue to the provider developers.\n\n"+ + "Vault response was nil", + ) + + return + } + + jsonData, err := json.Marshal(policyResp.Data) + if err != nil { + resp.Diagnostics.AddError( + "Unable to marshal Vault response", + "An unexpected error occurred while attempting to marshal the Vault response.\n\n"+ + "Error: "+err.Error(), + ) + + return + } + + var readResp *PasswordPolicyResourceAPIModel + err = json.Unmarshal(jsonData, &readResp) + if err != nil { + resp.Diagnostics.AddError( + "Unable to unmarshal data to API model", + "An unexpected error occurred while attempting to unmarshal the data.\n\n"+ + "Error: "+err.Error(), + ) + + return + } + + state.Policy = types.StringValue(readResp.Policy) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +func (r *PasswordPolicyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan PasswordPolicyModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + client, err := client.GetClient(ctx, r.meta, plan.Namespace.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error Configuring Resource Client", err.Error()) + return + } + + data := map[string]interface{}{ + "policy": plan.Policy.ValueString(), + } + path := r.path(plan.Name.ValueString()) + // vault returns a nil response on success + _, err = client.Logical().Write(path, data) + if err != nil { + resp.Diagnostics.AddError( + "Unable to Update Resource", + "An unexpected error occurred while attempting to update the resource. "+ + "Please retry the operation or report this issue to the provider developers.\n\n"+ + "HTTP Error: "+err.Error(), + ) + + return + } + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *PasswordPolicyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var plan PasswordPolicyModel + + // Read Terraform state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + client, err := client.GetClient(ctx, r.meta, plan.Namespace.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Error Configuring Resource Client", err.Error()) + return + } + + path := r.path(plan.Name.ValueString()) + + _, err = client.Logical().Delete(path) + if err != nil { + resp.Diagnostics.AddError( + "Unable to Delete Resource", + "An unexpected error occurred while attempting to delete the resource. "+ + "Please retry the operation or report this issue to the provider developers.\n\n"+ + "HTTP Error: "+err.Error(), + ) + + return + } + + // If the logic reaches here, it implicitly succeeded and will remove + // the resource from state if there are no other errors. +} + +func (r *PasswordPolicyResource) path(name string) string { + return fmt.Sprintf("/sys/policies/password/%s", name) +} diff --git a/vault/provider_test.go b/vault/provider_test.go index c85c08cf8..11d0f30f5 100644 --- a/vault/provider_test.go +++ b/vault/provider_test.go @@ -4,6 +4,7 @@ package vault import ( + "context" "fmt" "io/ioutil" "os" @@ -11,6 +12,7 @@ import ( "sync" "testing" + "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" @@ -108,16 +110,72 @@ const tokenHelperScript = `#!/usr/bin/env bash echo "helper-token" ` -func TestAccAuthLoginProviderConfigure(t *testing.T) { - rootProvider := Provider() - rootProviderResource := &schema.Resource{ - Schema: rootProvider.Schema, +// testAccProtoV5ProviderFactories will return a map of provider servers +// suitable for use as a resource.TestStep.ProtoV5ProviderFactories. +// +// When multiplexing providers, the schema and configuration handling must +// exactly match between all underlying providers of the mux server. Mismatched +// schemas will result in a runtime error. +// see: https://developer.hashicorp.com/terraform/plugin/framework/migrating/mux +// +// Any tests that use this function will serve as a smoketest to verify the +// provider schemas match 1-1 so that we may catch runtime errors. +func testAccProtoV5ProviderFactories(ctx context.Context, t *testing.T, v **schema.Provider) map[string]func() (tfprotov5.ProviderServer, error) { + providerServerFactory, p, err := ProtoV5ProviderServerFactory(ctx) + if err != nil { + t.Fatal(err) } + + providerServer := providerServerFactory() + *v = p.SchemaProvider() + + return map[string]func() (tfprotov5.ProviderServer, error){ + providerName: func() (tfprotov5.ProviderServer, error) { + return providerServer, nil + }, + } +} + +// TestAccMuxServer uses ExternalProviders (vault) to generate a state file +// with a previous version of the provider and then verify that there are no +// planned changes after migrating to the Framework. +// +// As of TFVP v3.23.0, the resources used in this test are not implemented with +// the new Terraform Plugin Framework. However, this will act as a smoketest to +// verify the provider schemas match 1-1. +// +// Additionally, when migrating a resource this test can be used as a pattern +// to follow to verify that switching from SDKv2 to the Framework has not +// affected your provider's behavior. +func TestAccMuxServer(t *testing.T) { + var p *schema.Provider resource.Test(t, resource.TestCase{ - PreCheck: func() { testutil.TestAccPreCheck(t) }, - Providers: map[string]*schema.Provider{ - "vault": rootProvider, + Steps: []resource.TestStep{ + { + ExternalProviders: map[string]resource.ExternalProvider{ + "vault": { + // 3.23.0 is not multiplexed + VersionConstraint: "3.23.0", + Source: "hashicorp/vault", + }, + }, + Config: testResourceApproleConfig_basic(), + Check: testResourceApproleLoginCheckAttrs(t), + }, + { + ProtoV5ProviderFactories: testAccProtoV5ProviderFactories(context.Background(), t, &p), + Config: testResourceApproleConfig_basic(), + PlanOnly: true, + }, }, + }) +} + +func TestAccAuthLoginProviderConfigure(t *testing.T) { + var p *schema.Provider + resource.Test(t, resource.TestCase{ + PreCheck: func() { testutil.TestAccPreCheck(t) }, + ProtoV5ProviderFactories: testAccProtoV5ProviderFactories(context.Background(), t, &p), Steps: []resource.TestStep{ { Config: testResourceApproleConfig_basic(), @@ -126,6 +184,9 @@ func TestAccAuthLoginProviderConfigure(t *testing.T) { }, }) + rootProviderResource := &schema.Resource{ + Schema: p.Schema, + } rootProviderData := rootProviderResource.TestResourceData() if _, err := provider.NewProviderMeta(rootProviderData); err != nil { t.Fatal(err) @@ -133,14 +194,11 @@ func TestAccAuthLoginProviderConfigure(t *testing.T) { } func TestTokenReadProviderConfigureWithHeaders(t *testing.T) { - rootProvider := Provider() + var p *schema.Provider - rootProviderResource := &schema.Resource{ - Schema: rootProvider.Schema, - } resource.Test(t, resource.TestCase{ - PreCheck: func() { testutil.TestAccPreCheck(t) }, - ProviderFactories: providerFactories, + PreCheck: func() { testutil.TestAccPreCheck(t) }, + ProtoV5ProviderFactories: testAccProtoV5ProviderFactories(context.Background(), t, &p), Steps: []resource.TestStep{ { Config: testHeaderConfig("auth", "123"), @@ -149,6 +207,9 @@ func TestTokenReadProviderConfigureWithHeaders(t *testing.T) { }, }) + rootProviderResource := &schema.Resource{ + Schema: p.Schema, + } rootProviderData := rootProviderResource.TestResourceData() if _, err := provider.NewProviderMeta(rootProviderData); err != nil { t.Fatal(err) @@ -548,11 +609,12 @@ func TestAccTokenName(t *testing.T) { }, } + var p *schema.Provider for _, test := range tests { t.Run(test.WantTokenName, func(t *testing.T) { resource.Test(t, resource.TestCase{ - ProviderFactories: providerFactories, - PreCheck: func() { testutil.TestAccPreCheck(t) }, + ProtoV5ProviderFactories: testAccProtoV5ProviderFactories(context.Background(), t, &p), + PreCheck: func() { testutil.TestAccPreCheck(t) }, Steps: []resource.TestStep{ { PreConfig: func() { @@ -611,11 +673,12 @@ func TestAccChildToken(t *testing.T) { }, } + var p *schema.Provider for name, test := range tests { t.Run(name, func(t *testing.T) { resource.Test(t, resource.TestCase{ - ProviderFactories: providerFactories, - PreCheck: func() { testutil.TestAccPreCheck(t) }, + ProtoV5ProviderFactories: testAccProtoV5ProviderFactories(context.Background(), t, &p), + PreCheck: func() { testutil.TestAccPreCheck(t) }, Steps: []resource.TestStep{ { Config: testProviderConfig(test.useChildTokenSchema, diff --git a/website/docs/index.html.markdown b/website/docs/index.html.markdown index 1bee9419c..0cbd799ad 100644 --- a/website/docs/index.html.markdown +++ b/website/docs/index.html.markdown @@ -9,7 +9,7 @@ description: |- # Vault Provider The Vault provider allows Terraform to read from, write to, and configure -[HashiCorp Vault](https://vaultproject.io/). +[HashiCorp Vault](https://developer.hashicorp.com/vault). ~> **Important** Interacting with Vault from Terraform causes any secrets that you read and write to be persisted in both Terraform's state file @@ -160,12 +160,6 @@ variables in order to keep credential information out of the configuration. a limited child token using auth/token/create in order to enforce a short TTL and limit exposure. *[See usage details below.](#generic)* -* `client_auth` - (Optional) A configuration block, described below, that - provides credentials used by Terraform to authenticate with the Vault - server. At present there is little reason to set this, because Terraform - does not support the TLS certificate authentication mechanism. - *Deprecated, use `auth_login_cert` instead. - * `skip_tls_verify` - (Optional) Set this to `true` to disable verification of the Vault server's TLS certificate. This is strongly discouraged except in prototype or development environments, since it exposes the possibility @@ -214,10 +208,6 @@ variables in order to keep credential information out of the configuration. * `use_root_namespace` - (Optional) Authenticate to the root Vault namespace. Conflicts with `namespace`. -* `set_namespace_from_token` -(Optional) Defaults to `true`. In the case where the Vault token is - for a specific namespace and the provider namespace is not configured, use the token namespace - as the root namespace for all resources. - * `skip_get_vault_version` - (Optional) Skip the dynamic fetching of the Vault server version. Set to `true` when the */sys/seal-status* API endpoint is not available. See [vault_version_override](#vault_version_override) for related info @@ -234,14 +224,6 @@ only ever use this option in the case where the server version cannot be dynamic to be sent along with all requests to the Vault server. This block can be specified multiple times. -The `client_auth` configuration block accepts the following arguments: - -* `cert_file` - (Required) Path to a file on local disk that contains the - PEM-encoded certificate to present to the server. - -* `key_file` - (Required) Path to a file on local disk that contains the - PEM-encoded private key for which the authentication certificate was issued. - The `headers` configuration block accepts the following arguments: * `name` - (Required) The name of the header. @@ -741,9 +723,9 @@ provider "vault" { The Vault provider supports managing [Namespaces][namespaces] (a feature of Vault Enterprise), as well as creating resources in those namespaces by utilizing [Provider Aliasing][aliasing]. The `namespace` option in the [provider -block][provider-block] enables the management of resources in the specified -namespace. -In addition, all resources and data sources support specifying their own `namespace`. +block](#provider-arguments) enables the management of resources in the specified +namespace. +In addition, all resources and data sources support specifying their own `namespace`. All resource's `namespace` will be made relative to the `provider`'s configured namespace. ### Importing namespaced resources @@ -966,11 +948,19 @@ default vault_team_policy ``` -## Tutorials +### Token namespaces + +In the case where the Vault token is for a specific namespace and the provider +namespace is not configured, the provider will use the token namespace as the +root namespace for all resources. This behavior can be disabled by setting the +`VAULT_SET_NAMESPACE_FROM_TOKEN ` environment variable to "false". The only +accepted values are "true" and "false". + + +## Tutorials Refer to the [Codify Management of Vault Enterprise Using Terraform](https://learn.hashicorp.com/tutorials/vault/codify-mgmt-enterprise) tutorial for additional examples using Vault namespaces. -[namespaces]: https://www.vaultproject.io/docs/enterprise/namespaces#vault-enterprise-namespaces -[aliasing]: https://www.terraform.io/docs/configuration/providers.html#alias-multiple-provider-configurations -[provider-block]: /docs#provider-arguments +[namespaces]: https://developer.hashicorp.com/vault/docs/enterprise/namespaces#vault-enterprise-namespaces +[aliasing]: https://developer.hashicorp.com/terraform/language/providers/configuration#alias-multiple-provider-configurations