github.com/terramate-io/tf@v0.0.0-20230830114523-fce866b4dfcd/backend/remote-state/s3/backend.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package s3
     5  
     6  import (
     7  	"encoding/base64"
     8  	"fmt"
     9  	"os"
    10  	"strings"
    11  
    12  	"github.com/aws/aws-sdk-go/aws"
    13  	"github.com/aws/aws-sdk-go/service/dynamodb"
    14  	"github.com/aws/aws-sdk-go/service/s3"
    15  	awsbase "github.com/hashicorp/aws-sdk-go-base"
    16  	"github.com/terramate-io/tf/backend"
    17  	"github.com/terramate-io/tf/configs/configschema"
    18  	"github.com/terramate-io/tf/logging"
    19  	"github.com/terramate-io/tf/tfdiags"
    20  	"github.com/terramate-io/tf/version"
    21  	"github.com/zclconf/go-cty/cty"
    22  	"github.com/zclconf/go-cty/cty/gocty"
    23  )
    24  
    25  func New() backend.Backend {
    26  	return &Backend{}
    27  }
    28  
    29  type Backend struct {
    30  	s3Client  *s3.S3
    31  	dynClient *dynamodb.DynamoDB
    32  
    33  	bucketName            string
    34  	keyName               string
    35  	serverSideEncryption  bool
    36  	customerEncryptionKey []byte
    37  	acl                   string
    38  	kmsKeyID              string
    39  	ddbTable              string
    40  	workspaceKeyPrefix    string
    41  }
    42  
    43  // ConfigSchema returns a description of the expected configuration
    44  // structure for the receiving backend.
    45  func (b *Backend) ConfigSchema() *configschema.Block {
    46  	return &configschema.Block{
    47  		Attributes: map[string]*configschema.Attribute{
    48  			"bucket": {
    49  				Type:        cty.String,
    50  				Required:    true,
    51  				Description: "The name of the S3 bucket",
    52  			},
    53  			"key": {
    54  				Type:        cty.String,
    55  				Required:    true,
    56  				Description: "The path to the state file inside the bucket",
    57  			},
    58  			"region": {
    59  				Type:        cty.String,
    60  				Optional:    true,
    61  				Description: "AWS region of the S3 Bucket and DynamoDB Table (if used).",
    62  			},
    63  			"dynamodb_endpoint": {
    64  				Type:        cty.String,
    65  				Optional:    true,
    66  				Description: "A custom endpoint for the DynamoDB API",
    67  			},
    68  			"endpoint": {
    69  				Type:        cty.String,
    70  				Optional:    true,
    71  				Description: "A custom endpoint for the S3 API",
    72  			},
    73  			"iam_endpoint": {
    74  				Type:        cty.String,
    75  				Optional:    true,
    76  				Description: "A custom endpoint for the IAM API",
    77  			},
    78  			"sts_endpoint": {
    79  				Type:        cty.String,
    80  				Optional:    true,
    81  				Description: "A custom endpoint for the STS API",
    82  			},
    83  			"encrypt": {
    84  				Type:        cty.Bool,
    85  				Optional:    true,
    86  				Description: "Whether to enable server side encryption of the state file",
    87  			},
    88  			"acl": {
    89  				Type:        cty.String,
    90  				Optional:    true,
    91  				Description: "Canned ACL to be applied to the state file",
    92  			},
    93  			"access_key": {
    94  				Type:        cty.String,
    95  				Optional:    true,
    96  				Description: "AWS access key",
    97  			},
    98  			"secret_key": {
    99  				Type:        cty.String,
   100  				Optional:    true,
   101  				Description: "AWS secret key",
   102  			},
   103  			"kms_key_id": {
   104  				Type:        cty.String,
   105  				Optional:    true,
   106  				Description: "The ARN of a KMS Key to use for encrypting the state",
   107  			},
   108  			"dynamodb_table": {
   109  				Type:        cty.String,
   110  				Optional:    true,
   111  				Description: "DynamoDB table for state locking and consistency",
   112  			},
   113  			"profile": {
   114  				Type:        cty.String,
   115  				Optional:    true,
   116  				Description: "AWS profile name",
   117  			},
   118  			"shared_credentials_file": {
   119  				Type:        cty.String,
   120  				Optional:    true,
   121  				Description: "Path to a shared credentials file",
   122  			},
   123  			"token": {
   124  				Type:        cty.String,
   125  				Optional:    true,
   126  				Description: "MFA token",
   127  			},
   128  			"skip_credentials_validation": {
   129  				Type:        cty.Bool,
   130  				Optional:    true,
   131  				Description: "Skip the credentials validation via STS API.",
   132  			},
   133  			"skip_metadata_api_check": {
   134  				Type:        cty.Bool,
   135  				Optional:    true,
   136  				Description: "Skip the AWS Metadata API check.",
   137  			},
   138  			"skip_region_validation": {
   139  				Type:        cty.Bool,
   140  				Optional:    true,
   141  				Description: "Skip static validation of region name.",
   142  			},
   143  			"sse_customer_key": {
   144  				Type:        cty.String,
   145  				Optional:    true,
   146  				Description: "The base64-encoded encryption key to use for server-side encryption with customer-provided keys (SSE-C).",
   147  				Sensitive:   true,
   148  			},
   149  			"role_arn": {
   150  				Type:        cty.String,
   151  				Optional:    true,
   152  				Description: "The role to be assumed",
   153  			},
   154  			"session_name": {
   155  				Type:        cty.String,
   156  				Optional:    true,
   157  				Description: "The session name to use when assuming the role.",
   158  			},
   159  			"external_id": {
   160  				Type:        cty.String,
   161  				Optional:    true,
   162  				Description: "The external ID to use when assuming the role",
   163  			},
   164  
   165  			"assume_role_duration_seconds": {
   166  				Type:        cty.Number,
   167  				Optional:    true,
   168  				Description: "Seconds to restrict the assume role session duration.",
   169  			},
   170  
   171  			"assume_role_policy": {
   172  				Type:        cty.String,
   173  				Optional:    true,
   174  				Description: "IAM Policy JSON describing further restricting permissions for the IAM Role being assumed.",
   175  			},
   176  
   177  			"assume_role_policy_arns": {
   178  				Type:        cty.Set(cty.String),
   179  				Optional:    true,
   180  				Description: "Amazon Resource Names (ARNs) of IAM Policies describing further restricting permissions for the IAM Role being assumed.",
   181  			},
   182  
   183  			"assume_role_tags": {
   184  				Type:        cty.Map(cty.String),
   185  				Optional:    true,
   186  				Description: "Assume role session tags.",
   187  			},
   188  
   189  			"assume_role_transitive_tag_keys": {
   190  				Type:        cty.Set(cty.String),
   191  				Optional:    true,
   192  				Description: "Assume role session tag keys to pass to any subsequent sessions.",
   193  			},
   194  
   195  			"workspace_key_prefix": {
   196  				Type:        cty.String,
   197  				Optional:    true,
   198  				Description: "The prefix applied to the non-default state path inside the bucket.",
   199  			},
   200  
   201  			"force_path_style": {
   202  				Type:        cty.Bool,
   203  				Optional:    true,
   204  				Description: "Force s3 to use path style api.",
   205  			},
   206  
   207  			"max_retries": {
   208  				Type:        cty.Number,
   209  				Optional:    true,
   210  				Description: "The maximum number of times an AWS API request is retried on retryable failure.",
   211  			},
   212  		},
   213  	}
   214  }
   215  
   216  // PrepareConfig checks the validity of the values in the given
   217  // configuration, and inserts any missing defaults, assuming that its
   218  // structure has already been validated per the schema returned by
   219  // ConfigSchema.
   220  func (b *Backend) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) {
   221  	var diags tfdiags.Diagnostics
   222  	if obj.IsNull() {
   223  		return obj, diags
   224  	}
   225  
   226  	if val := obj.GetAttr("bucket"); val.IsNull() || val.AsString() == "" {
   227  		diags = diags.Append(tfdiags.AttributeValue(
   228  			tfdiags.Error,
   229  			"Invalid bucket value",
   230  			`The "bucket" attribute value must not be empty.`,
   231  			cty.Path{cty.GetAttrStep{Name: "bucket"}},
   232  		))
   233  	}
   234  
   235  	if val := obj.GetAttr("key"); val.IsNull() || val.AsString() == "" {
   236  		diags = diags.Append(tfdiags.AttributeValue(
   237  			tfdiags.Error,
   238  			"Invalid key value",
   239  			`The "key" attribute value must not be empty.`,
   240  			cty.Path{cty.GetAttrStep{Name: "key"}},
   241  		))
   242  	} else if strings.HasPrefix(val.AsString(), "/") || strings.HasSuffix(val.AsString(), "/") {
   243  		// S3 will strip leading slashes from an object, so while this will
   244  		// technically be accepted by S3, it will break our workspace hierarchy.
   245  		// S3 will recognize objects with a trailing slash as a directory
   246  		// so they should not be valid keys
   247  		diags = diags.Append(tfdiags.AttributeValue(
   248  			tfdiags.Error,
   249  			"Invalid key value",
   250  			`The "key" attribute value must not start or end with with "/".`,
   251  			cty.Path{cty.GetAttrStep{Name: "key"}},
   252  		))
   253  	}
   254  
   255  	if val := obj.GetAttr("region"); val.IsNull() || val.AsString() == "" {
   256  		if os.Getenv("AWS_REGION") == "" && os.Getenv("AWS_DEFAULT_REGION") == "" {
   257  			diags = diags.Append(tfdiags.AttributeValue(
   258  				tfdiags.Error,
   259  				"Missing region value",
   260  				`The "region" attribute or the "AWS_REGION" or "AWS_DEFAULT_REGION" environment variables must be set.`,
   261  				cty.Path{cty.GetAttrStep{Name: "region"}},
   262  			))
   263  		}
   264  	}
   265  
   266  	if val := obj.GetAttr("kms_key_id"); !val.IsNull() && val.AsString() != "" {
   267  		if val := obj.GetAttr("sse_customer_key"); !val.IsNull() && val.AsString() != "" {
   268  			diags = diags.Append(tfdiags.AttributeValue(
   269  				tfdiags.Error,
   270  				"Invalid encryption configuration",
   271  				encryptionKeyConflictError,
   272  				cty.Path{},
   273  			))
   274  		} else if customerKey := os.Getenv("AWS_SSE_CUSTOMER_KEY"); customerKey != "" {
   275  			diags = diags.Append(tfdiags.AttributeValue(
   276  				tfdiags.Error,
   277  				"Invalid encryption configuration",
   278  				encryptionKeyConflictEnvVarError,
   279  				cty.Path{},
   280  			))
   281  		}
   282  
   283  		diags = diags.Append(validateKMSKey(cty.Path{cty.GetAttrStep{Name: "kms_key_id"}}, val.AsString()))
   284  	}
   285  
   286  	if val := obj.GetAttr("workspace_key_prefix"); !val.IsNull() {
   287  		if v := val.AsString(); strings.HasPrefix(v, "/") || strings.HasSuffix(v, "/") {
   288  			diags = diags.Append(tfdiags.AttributeValue(
   289  				tfdiags.Error,
   290  				"Invalid workspace_key_prefix value",
   291  				`The "workspace_key_prefix" attribute value must not start with "/".`,
   292  				cty.Path{cty.GetAttrStep{Name: "workspace_key_prefix"}},
   293  			))
   294  		}
   295  	}
   296  
   297  	return obj, diags
   298  }
   299  
   300  // Configure uses the provided configuration to set configuration fields
   301  // within the backend.
   302  //
   303  // The given configuration is assumed to have already been validated
   304  // against the schema returned by ConfigSchema and passed validation
   305  // via PrepareConfig.
   306  func (b *Backend) Configure(obj cty.Value) tfdiags.Diagnostics {
   307  	var diags tfdiags.Diagnostics
   308  	if obj.IsNull() {
   309  		return diags
   310  	}
   311  
   312  	var region string
   313  	if v, ok := stringAttrOk(obj, "region"); ok {
   314  		region = v
   315  	}
   316  
   317  	if region != "" && !boolAttr(obj, "skip_region_validation") {
   318  		if err := awsbase.ValidateRegion(region); err != nil {
   319  			diags = diags.Append(tfdiags.AttributeValue(
   320  				tfdiags.Error,
   321  				"Invalid region value",
   322  				err.Error(),
   323  				cty.Path{cty.GetAttrStep{Name: "region"}},
   324  			))
   325  			return diags
   326  		}
   327  	}
   328  
   329  	b.bucketName = stringAttr(obj, "bucket")
   330  	b.keyName = stringAttr(obj, "key")
   331  	b.acl = stringAttr(obj, "acl")
   332  	b.workspaceKeyPrefix = stringAttrDefault(obj, "workspace_key_prefix", "env:")
   333  	b.serverSideEncryption = boolAttr(obj, "encrypt")
   334  	b.kmsKeyID = stringAttr(obj, "kms_key_id")
   335  	b.ddbTable = stringAttr(obj, "dynamodb_table")
   336  
   337  	if customerKey, ok := stringAttrOk(obj, "sse_customer_key"); ok {
   338  		if len(customerKey) != 44 {
   339  			diags = diags.Append(tfdiags.AttributeValue(
   340  				tfdiags.Error,
   341  				"Invalid sse_customer_key value",
   342  				"sse_customer_key must be 44 characters in length",
   343  				cty.Path{cty.GetAttrStep{Name: "sse_customer_key"}},
   344  			))
   345  		} else {
   346  			var err error
   347  			if b.customerEncryptionKey, err = base64.StdEncoding.DecodeString(customerKey); err != nil {
   348  				diags = diags.Append(tfdiags.AttributeValue(
   349  					tfdiags.Error,
   350  					"Invalid sse_customer_key value",
   351  					fmt.Sprintf("sse_customer_key must be base64 encoded: %s", err),
   352  					cty.Path{cty.GetAttrStep{Name: "sse_customer_key"}},
   353  				))
   354  			}
   355  		}
   356  	} else if customerKey := os.Getenv("AWS_SSE_CUSTOMER_KEY"); customerKey != "" {
   357  		if len(customerKey) != 44 {
   358  			diags = diags.Append(tfdiags.Sourceless(
   359  				tfdiags.Error,
   360  				"Invalid AWS_SSE_CUSTOMER_KEY value",
   361  				`The environment variable "AWS_SSE_CUSTOMER_KEY" must be 44 characters in length`,
   362  			))
   363  		} else {
   364  			var err error
   365  			if b.customerEncryptionKey, err = base64.StdEncoding.DecodeString(customerKey); err != nil {
   366  				diags = diags.Append(tfdiags.Sourceless(
   367  					tfdiags.Error,
   368  					"Invalid AWS_SSE_CUSTOMER_KEY value",
   369  					fmt.Sprintf(`The environment variable "AWS_SSE_CUSTOMER_KEY" must be base64 encoded: %s`, err),
   370  				))
   371  			}
   372  		}
   373  	}
   374  
   375  	cfg := &awsbase.Config{
   376  		AccessKey:                 stringAttr(obj, "access_key"),
   377  		AssumeRoleARN:             stringAttr(obj, "role_arn"),
   378  		AssumeRoleDurationSeconds: intAttr(obj, "assume_role_duration_seconds"),
   379  		AssumeRoleExternalID:      stringAttr(obj, "external_id"),
   380  		AssumeRolePolicy:          stringAttr(obj, "assume_role_policy"),
   381  		AssumeRoleSessionName:     stringAttr(obj, "session_name"),
   382  		CallerDocumentationURL:    "https://www.terraform.io/docs/language/settings/backends/s3.html",
   383  		CallerName:                "S3 Backend",
   384  		CredsFilename:             stringAttr(obj, "shared_credentials_file"),
   385  		DebugLogging:              logging.IsDebugOrHigher(),
   386  		IamEndpoint:               stringAttrDefaultEnvVar(obj, "iam_endpoint", "AWS_IAM_ENDPOINT"),
   387  		MaxRetries:                intAttrDefault(obj, "max_retries", 5),
   388  		Profile:                   stringAttr(obj, "profile"),
   389  		Region:                    stringAttr(obj, "region"),
   390  		SecretKey:                 stringAttr(obj, "secret_key"),
   391  		SkipCredsValidation:       boolAttr(obj, "skip_credentials_validation"),
   392  		SkipMetadataApiCheck:      boolAttr(obj, "skip_metadata_api_check"),
   393  		StsEndpoint:               stringAttrDefaultEnvVar(obj, "sts_endpoint", "AWS_STS_ENDPOINT"),
   394  		Token:                     stringAttr(obj, "token"),
   395  		UserAgentProducts: []*awsbase.UserAgentProduct{
   396  			{Name: "APN", Version: "1.0"},
   397  			{Name: "HashiCorp", Version: "1.0"},
   398  			{Name: "Terraform", Version: version.String()},
   399  		},
   400  	}
   401  
   402  	if policyARNSet := obj.GetAttr("assume_role_policy_arns"); !policyARNSet.IsNull() {
   403  		policyARNSet.ForEachElement(func(key, val cty.Value) (stop bool) {
   404  			v, ok := stringValueOk(val)
   405  			if ok {
   406  				cfg.AssumeRolePolicyARNs = append(cfg.AssumeRolePolicyARNs, v)
   407  			}
   408  			return
   409  		})
   410  	}
   411  
   412  	if tagMap := obj.GetAttr("assume_role_tags"); !tagMap.IsNull() {
   413  		cfg.AssumeRoleTags = make(map[string]string, tagMap.LengthInt())
   414  		tagMap.ForEachElement(func(key, val cty.Value) (stop bool) {
   415  			k := stringValue(key)
   416  			v, ok := stringValueOk(val)
   417  			if ok {
   418  				cfg.AssumeRoleTags[k] = v
   419  			}
   420  			return
   421  		})
   422  	}
   423  
   424  	if transitiveTagKeySet := obj.GetAttr("assume_role_transitive_tag_keys"); !transitiveTagKeySet.IsNull() {
   425  		transitiveTagKeySet.ForEachElement(func(key, val cty.Value) (stop bool) {
   426  			v, ok := stringValueOk(val)
   427  			if ok {
   428  				cfg.AssumeRoleTransitiveTagKeys = append(cfg.AssumeRoleTransitiveTagKeys, v)
   429  			}
   430  			return
   431  		})
   432  	}
   433  
   434  	sess, err := awsbase.GetSession(cfg)
   435  	if err != nil {
   436  		diags = diags.Append(tfdiags.Sourceless(
   437  			tfdiags.Error,
   438  			"Failed to configure AWS client",
   439  			fmt.Sprintf(`The "S3" backend encountered an unexpected error while creating the AWS client: %s`, err),
   440  		))
   441  		return diags
   442  	}
   443  
   444  	var dynamoConfig aws.Config
   445  	if v, ok := stringAttrDefaultEnvVarOk(obj, "dynamodb_endpoint", "AWS_DYNAMODB_ENDPOINT"); ok {
   446  		dynamoConfig.Endpoint = aws.String(v)
   447  	}
   448  	b.dynClient = dynamodb.New(sess.Copy(&dynamoConfig))
   449  
   450  	var s3Config aws.Config
   451  	if v, ok := stringAttrDefaultEnvVarOk(obj, "endpoint", "AWS_S3_ENDPOINT"); ok {
   452  		s3Config.Endpoint = aws.String(v)
   453  	}
   454  	if v, ok := boolAttrOk(obj, "force_path_style"); ok {
   455  		s3Config.S3ForcePathStyle = aws.Bool(v)
   456  	}
   457  	b.s3Client = s3.New(sess.Copy(&s3Config))
   458  
   459  	return diags
   460  }
   461  
   462  func stringValue(val cty.Value) string {
   463  	v, _ := stringValueOk(val)
   464  	return v
   465  }
   466  
   467  func stringValueOk(val cty.Value) (string, bool) {
   468  	if val.IsNull() {
   469  		return "", false
   470  	} else {
   471  		return val.AsString(), true
   472  	}
   473  }
   474  
   475  func stringAttr(obj cty.Value, name string) string {
   476  	return stringValue(obj.GetAttr(name))
   477  }
   478  
   479  func stringAttrOk(obj cty.Value, name string) (string, bool) {
   480  	return stringValueOk(obj.GetAttr(name))
   481  }
   482  
   483  func stringAttrDefault(obj cty.Value, name, def string) string {
   484  	if v, ok := stringAttrOk(obj, name); !ok {
   485  		return def
   486  	} else {
   487  		return v
   488  	}
   489  }
   490  
   491  func stringAttrDefaultEnvVar(obj cty.Value, name string, envvars ...string) string {
   492  	if v, ok := stringAttrDefaultEnvVarOk(obj, name, envvars...); !ok {
   493  		return ""
   494  	} else {
   495  		return v
   496  	}
   497  }
   498  
   499  func stringAttrDefaultEnvVarOk(obj cty.Value, name string, envvars ...string) (string, bool) {
   500  	if v, ok := stringAttrOk(obj, name); !ok {
   501  		for _, envvar := range envvars {
   502  			if v := os.Getenv(envvar); v != "" {
   503  				return v, true
   504  			}
   505  		}
   506  		return "", false
   507  	} else {
   508  		return v, true
   509  	}
   510  }
   511  
   512  func boolAttr(obj cty.Value, name string) bool {
   513  	v, _ := boolAttrOk(obj, name)
   514  	return v
   515  }
   516  
   517  func boolAttrOk(obj cty.Value, name string) (bool, bool) {
   518  	if val := obj.GetAttr(name); val.IsNull() {
   519  		return false, false
   520  	} else {
   521  		return val.True(), true
   522  	}
   523  }
   524  
   525  func intAttr(obj cty.Value, name string) int {
   526  	v, _ := intAttrOk(obj, name)
   527  	return v
   528  }
   529  
   530  func intAttrOk(obj cty.Value, name string) (int, bool) {
   531  	if val := obj.GetAttr(name); val.IsNull() {
   532  		return 0, false
   533  	} else {
   534  		var v int
   535  		if err := gocty.FromCtyValue(val, &v); err != nil {
   536  			return 0, false
   537  		}
   538  		return v, true
   539  	}
   540  }
   541  
   542  func intAttrDefault(obj cty.Value, name string, def int) int {
   543  	if v, ok := intAttrOk(obj, name); !ok {
   544  		return def
   545  	} else {
   546  		return v
   547  	}
   548  }
   549  
   550  const encryptionKeyConflictError = `Only one of "kms_key_id" and "sse_customer_key" can be set.
   551  
   552  The "kms_key_id" is used for encryption with KMS-Managed Keys (SSE-KMS)
   553  while "sse_customer_key" is used for encryption with customer-managed keys (SSE-C).
   554  Please choose one or the other.`
   555  
   556  const encryptionKeyConflictEnvVarError = `Only one of "kms_key_id" and the environment variable "AWS_SSE_CUSTOMER_KEY" can be set.
   557  
   558  The "kms_key_id" is used for encryption with KMS-Managed Keys (SSE-KMS)
   559  while "AWS_SSE_CUSTOMER_KEY" is used for encryption with customer-managed keys (SSE-C).
   560  Please choose one or the other.`