diff --git a/CHANGELOG.md b/CHANGELOG.md index eb731d08..344bb704 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ -# 2.4.0 (2025-**-**) +# 2.4.0 + +- Configured SSL certificate based authentication in addition to basic username and password authentication in provider config. # 2.3.0 (2025-07-22) diff --git a/examples/provider/provider.tf b/examples/provider/provider.tf index 354f521f..721738f2 100644 --- a/examples/provider/provider.tf +++ b/examples/provider/provider.tf @@ -53,6 +53,14 @@ provider "netapp-ontap" { username = var.username password = var.password validate_certs = var.validate_certs + }, + { + name = "cluster6" + hostname = "********82" + cert_filepath = var.cert_filepath + key_filepath = var.key_filepath + ca_cert_file = var.ca_cert_file + validate_certs = var.validate_certs } ] } diff --git a/examples/provider/terraform.tfvars b/examples/provider/terraform.tfvars index d36beb44..a562d676 100644 --- a/examples/provider/terraform.tfvars +++ b/examples/provider/terraform.tfvars @@ -1,3 +1,11 @@ -username = "admin" -password = "xxxxxxxxx" -validate_certs = true +# Basic username password authentication + +username = "tfuser" +password = "netapp1!" +validate_certs = false + +# cert based authentication + +# cert_filepath = "/root/cert/terraform_client.crt" +# key_filepath = "/root/cert/terraform_client.key" +# ca_cert_file = "/root/cert/rootCA.crt" diff --git a/examples/provider/variables.tf b/examples/provider/variables.tf index b79f07ed..0d44e4f5 100644 --- a/examples/provider/variables.tf +++ b/examples/provider/variables.tf @@ -1,11 +1,26 @@ # Terraform will prompt for values, unless a tfvars file is present. variable "username" { type = string + default = null } variable "password" { type = string sensitive = true + default = null } variable "validate_certs" { type = bool + default = false } +variable "client_cert_file" { + type = string + default = null +} +variable "client_key_file" { + type = string + default = null +} +variable "ca_cert_file" { + type = string + default = null +} \ No newline at end of file diff --git a/internal/provider/connection/config.go b/internal/provider/connection/config.go index 90aa9454..217430b0 100644 --- a/internal/provider/connection/config.go +++ b/internal/provider/connection/config.go @@ -23,6 +23,9 @@ type Profile struct { MaxConcurrentRequests int UseAWSLambda bool AWS AWSConfig `mapstructure:"aws,omitempty"` + CertFilepath string + KeyFilepath string + CACertFile string } type AWSConfig struct { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 9e5589fb..fc43ff89 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -45,6 +45,9 @@ type ConnectionProfileModel struct { Password types.String `tfsdk:"password"` ValidateCerts types.Bool `tfsdk:"validate_certs"` ONTAPProviderAWSModel types.Object `tfsdk:"aws_lambda"` + CertFilepath types.String `tfsdk:"cert_filepath"` + KeyFilepath types.String `tfsdk:"key_filepath"` + CACertFile types.String `tfsdk:"ca_cert_file"` } // ONTAPProviderModel describes the provider data model. @@ -94,17 +97,29 @@ func (p *ONTAPProvider) Schema(ctx context.Context, req provider.SchemaRequest, }, "username": schema.StringAttribute{ MarkdownDescription: "ONTAP management user name (cluster or svm)", - Required: true, + Optional: true, }, "password": schema.StringAttribute{ MarkdownDescription: "ONTAP management password for username", - Required: true, + Optional: true, Sensitive: true, }, "validate_certs": schema.BoolAttribute{ MarkdownDescription: "Whether to enforce SSL certificate validation, defaults to true. Not applicable for AWS Lambda", Optional: true, }, + "key_filepath": schema.StringAttribute{ + MarkdownDescription: "Path to the key file. Not applicable for AWS Lambda", + Optional: true, + }, + "cert_filepath": schema.StringAttribute{ + MarkdownDescription: "Path to the certificate file. Not applicable for AWS Lambda", + Optional: true, + }, + "ca_cert_file": schema.StringAttribute{ + MarkdownDescription: "Path to the CA certificate file. Not applicable for AWS Lambda", + Optional: true, + }, "aws_lambda": schema.SingleNestedAttribute{ MarkdownDescription: "AWS configuration for Lambda", Optional: true, @@ -178,6 +193,9 @@ func (p *ONTAPProvider) Configure(ctx context.Context, req provider.ConfigureReq Password: connectionProfile.Password.ValueString(), ValidateCerts: validateCerts, MaxConcurrentRequests: 0, + CertFilepath: connectionProfile.CertFilepath.ValueString(), + KeyFilepath: connectionProfile.KeyFilepath.ValueString(), + CACertFile: connectionProfile.CACertFile.ValueString(), } if !connectionProfile.ONTAPProviderAWSModel.IsNull() { var lambdaConfig ONTAPProviderAWSLambdaModel diff --git a/internal/restclient/httpclient/http_client.go b/internal/restclient/httpclient/http_client.go index 4850efa5..99d1d096 100644 --- a/internal/restclient/httpclient/http_client.go +++ b/internal/restclient/httpclient/http_client.go @@ -3,9 +3,12 @@ package httpclient import ( "context" "crypto/tls" + "crypto/x509" + "errors" "fmt" "io" "net/http" + "os" "time" "github.com/hashicorp/terraform-plugin-log/tflog" @@ -26,6 +29,47 @@ type HTTPProfile struct { Username string Password string ValidateCerts bool + CertFilepath string + KeyFilepath string + CACertFile string +} + +// AuthMethod represents the authentication method to use +type AuthMethod int + +const ( + AuthMethodNone AuthMethod = iota + AuthMethodBasic + AuthMethodSingleCert // single_cert - cert file only + AuthMethodCertKey // cert_key - cert and key files +) + +// setAuthMethod determines the authentication method based on available credentials +func (c *HTTPClient) setAuthMethod() (AuthMethod, error) { + // cert authentication is prioritized if both basic and client certificate authentication parameters are given + if c.cxProfile.CertFilepath != "" { + if c.cxProfile.KeyFilepath == "" { + // single_cert method (cert file only, no key file) + return AuthMethodSingleCert, nil + } else { + // cert_key method (both cert and key files) + return AuthMethodCertKey, nil + } + } else { + // No certificate file provided + if c.cxProfile.Password == "" && c.cxProfile.Username == "" { + if c.cxProfile.KeyFilepath != "" { + return AuthMethodNone, errors.New("cannot have a key file without a cert file") + } else { + return AuthMethodNone, errors.New("ONTAP module requires username/password or SSL certificate file(s)") + } + } else if c.cxProfile.Password != "" && c.cxProfile.Username != "" { + // Both username and password provided - use basic auth + return AuthMethodBasic, nil + } else { + return AuthMethodNone, errors.New("username and password have to be provided together") + } + } } // Do sends the API Request, parses the response as JSON, and returns the HTTP status code as int, the "result" value as byte @@ -80,10 +124,73 @@ func NewClient(ctx context.Context, cxProfile HTTPProfile, tag string) HTTPClien return client } -// create configures and creates the http client func (c HTTPClient) create() http.Client { - if !c.cxProfile.ValidateCerts { - http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + tlsConfig := &tls.Config{ + InsecureSkipVerify: !c.cxProfile.ValidateCerts, + // When using client certificates, we might want to skip server cert validation + // but still validate client certs + } + + // Determine and log authentication method + authMethod, err := c.setAuthMethod() + if err != nil { + tflog.Error(c.ctx, fmt.Sprintf("Authentication configuration error: %v", err)) + } else { + var authDesc string + switch authMethod { + case AuthMethodBasic: + authDesc = "basic_auth (username/password)" + tflog.Debug(c.ctx, "Using basic authentication") + case AuthMethodSingleCert: + authDesc = "single_cert (certificate only)" + tflog.Debug(c.ctx, "Using single certificate authentication") + case AuthMethodCertKey: + authDesc = "cert_key (certificate with key)" + tflog.Debug(c.ctx, "Using certificate key authentication") + default: + authDesc = "none" + } + tflog.Debug(c.ctx, fmt.Sprintf("Using authentication method: %s", authDesc)) + } + + // Load client cert/key if provided (for cert-based auth) + if c.cxProfile.CertFilepath != "" { + if c.cxProfile.KeyFilepath != "" { + // cert_key method - load both cert and key + cert, err := tls.LoadX509KeyPair(c.cxProfile.CertFilepath, c.cxProfile.KeyFilepath) + if err != nil { + tflog.Error(c.ctx, fmt.Sprintf("Failed to load client certificate: %v", err)) + } else { + tlsConfig.Certificates = []tls.Certificate{cert} + tflog.Debug(c.ctx, "Client certificate and key loaded successfully") + } + } else { + // single_cert method - load only certificate (no private key) + // This is typically used for server certificate validation, not client auth + tflog.Debug(c.ctx, "Single certificate mode - certificate file provided without key") + } + } + + // Load CA cert if provided + if c.cxProfile.CACertFile != "" { + caCertPool := x509.NewCertPool() + caCert, err := os.ReadFile(c.cxProfile.CACertFile) + if err != nil { + tflog.Error(c.ctx, fmt.Sprintf("Failed to load CA certificate: %v", err)) + } else { + if caCertPool.AppendCertsFromPEM(caCert) { + tlsConfig.RootCAs = caCertPool + tflog.Debug(c.ctx, "CA certificate loaded successfully") + } else { + tflog.Error(c.ctx, "Failed to parse CA certificate") + } + } + } + + return http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + Timeout: 120 * time.Second, } - return http.Client{Timeout: 120 * time.Second} } diff --git a/internal/restclient/httpclient/request.go b/internal/restclient/httpclient/request.go index 14cacd6f..e633635b 100644 --- a/internal/restclient/httpclient/request.go +++ b/internal/restclient/httpclient/request.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io" "net/http" "net/url" @@ -40,7 +41,24 @@ func (r *Request) BuildHTTPReq(c *HTTPClient, baseURL string) (*http.Request, er } req.Header.Set("Content-Type", "application/json") - req.SetBasicAuth(c.cxProfile.Username, c.cxProfile.Password) + + authMethod, err := c.setAuthMethod() + if err != nil { + return nil, fmt.Errorf("authentication error: %v", err) + } + + // Apply authentication based on the determined method + switch authMethod { + case AuthMethodBasic: + // basic_auth - use username and password + req.SetBasicAuth(c.cxProfile.Username, c.cxProfile.Password) + case AuthMethodSingleCert: + // single_cert - certificate authentication only (no Basic Auth headers) + // Certificate authentication is handled at TLS level in http.Client + case AuthMethodCertKey: + // cert_key - certificate with key authentication (no Basic Auth headers) + // Certificate authentication is handled at TLS level in http.Client + } // telemetry header req.Header.Set("X-Dot-Client-App", c.tag) // TODO: low pty: add support for form data (require to create a file) diff --git a/internal/restclient/rest_client.go b/internal/restclient/rest_client.go index 2d9986ef..4a8f0764 100644 --- a/internal/restclient/rest_client.go +++ b/internal/restclient/rest_client.go @@ -26,6 +26,9 @@ type ConnectionProfile struct { MaxConcurrentRequests int UseAWSLambda bool AWS AWSConfig `mapstructure:"AWS,omitempty"` + CertFilepath string + KeyFilepath string + CACertFile string } type AWSConfig struct { @@ -219,6 +222,9 @@ func NewClient(ctx context.Context, cxProfile ConnectionProfile, tag string, job return nil, errors.New(msg) } httpProfile.APIRoot = "api" + httpProfile.CertFilepath = cxProfile.CertFilepath + httpProfile.KeyFilepath = cxProfile.KeyFilepath + httpProfile.CACertFile = cxProfile.CACertFile maxConcurrentRequests := cxProfile.MaxConcurrentRequests if maxConcurrentRequests == 0 { maxConcurrentRequests = 6