package s3 import ( "context" "encoding/base64" "errors" "fmt" "strings" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/dynamodb" "github.com/aws/aws-sdk-go/service/s3" awsbase "github.com/hashicorp/aws-sdk-go-base" "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/helper/logging" "github.com/hashicorp/terraform/helper/schema" "github.com/hashicorp/terraform/version" ) // New creates a new backend for S3 remote state. func New() backend.Backend { s := &schema.Backend{ Schema: map[string]*schema.Schema{ "bucket": { Type: schema.TypeString, Required: true, Description: "The name of the S3 bucket", }, "key": { Type: schema.TypeString, Required: true, Description: "The path to the state file inside the bucket", ValidateFunc: func(v interface{}, s string) ([]string, []error) { // s3 will strip leading slashes from an object, so while this will // technically be accepted by s3, it will break our workspace hierarchy. if strings.HasPrefix(v.(string), "/") { return nil, []error{errors.New("key must not start with '/'")} } return nil, nil }, }, "region": { Type: schema.TypeString, Required: true, Description: "AWS region of the S3 Bucket and DynamoDB Table (if used).", DefaultFunc: schema.MultiEnvDefaultFunc([]string{ "AWS_REGION", "AWS_DEFAULT_REGION", }, nil), }, "dynamodb_endpoint": { Type: schema.TypeString, Optional: true, Description: "A custom endpoint for the DynamoDB API", DefaultFunc: schema.EnvDefaultFunc("AWS_DYNAMODB_ENDPOINT", ""), }, "endpoint": { Type: schema.TypeString, Optional: true, Description: "A custom endpoint for the S3 API", DefaultFunc: schema.EnvDefaultFunc("AWS_S3_ENDPOINT", ""), }, "iam_endpoint": { Type: schema.TypeString, Optional: true, Description: "A custom endpoint for the IAM API", DefaultFunc: schema.EnvDefaultFunc("AWS_IAM_ENDPOINT", ""), }, "sts_endpoint": { Type: schema.TypeString, Optional: true, Description: "A custom endpoint for the STS API", DefaultFunc: schema.EnvDefaultFunc("AWS_STS_ENDPOINT", ""), }, "encrypt": { Type: schema.TypeBool, Optional: true, Description: "Whether to enable server side encryption of the state file", Default: false, }, "acl": { Type: schema.TypeString, Optional: true, Description: "Canned ACL to be applied to the state file", Default: "", }, "access_key": { Type: schema.TypeString, Optional: true, Description: "AWS access key", Default: "", }, "secret_key": { Type: schema.TypeString, Optional: true, Description: "AWS secret key", Default: "", }, "kms_key_id": { Type: schema.TypeString, Optional: true, Description: "The ARN of a KMS Key to use for encrypting the state", Default: "", }, "dynamodb_table": { Type: schema.TypeString, Optional: true, Description: "DynamoDB table for state locking and consistency", Default: "", }, "profile": { Type: schema.TypeString, Optional: true, Description: "AWS profile name", Default: "", }, "shared_credentials_file": { Type: schema.TypeString, Optional: true, Description: "Path to a shared credentials file", Default: "", }, "token": { Type: schema.TypeString, Optional: true, Description: "MFA token", Default: "", }, "skip_credentials_validation": { Type: schema.TypeBool, Optional: true, Description: "Skip the credentials validation via STS API.", Default: false, }, "skip_region_validation": { Type: schema.TypeBool, Optional: true, Description: "Skip static validation of region name.", Default: false, }, "skip_metadata_api_check": { Type: schema.TypeBool, Optional: true, Description: "Skip the AWS Metadata API check.", Default: false, }, "sse_customer_key": { Type: schema.TypeString, Optional: true, Description: "The base64-encoded encryption key to use for server-side encryption with customer-provided keys (SSE-C).", DefaultFunc: schema.EnvDefaultFunc("AWS_SSE_CUSTOMER_KEY", ""), Sensitive: true, ValidateFunc: func(v interface{}, s string) ([]string, []error) { key := v.(string) if key != "" && len(key) != 44 { return nil, []error{errors.New("sse_customer_key must be 44 characters in length (256 bits, base64 encoded)")} } return nil, nil }, }, "role_arn": { Type: schema.TypeString, Optional: true, Description: "The role to be assumed", Default: "", }, "session_name": { Type: schema.TypeString, Optional: true, Description: "The session name to use when assuming the role.", Default: "", }, "external_id": { Type: schema.TypeString, Optional: true, Description: "The external ID to use when assuming the role", Default: "", }, "assume_role_duration_seconds": { Type: schema.TypeInt, Optional: true, Description: "Seconds to restrict the assume role session duration.", }, "assume_role_policy": { Type: schema.TypeString, Optional: true, Description: "IAM Policy JSON describing further restricting permissions for the IAM Role being assumed.", Default: "", }, "assume_role_policy_arns": { Type: schema.TypeSet, Optional: true, Description: "Amazon Resource Names (ARNs) of IAM Policies describing further restricting permissions for the IAM Role being assumed.", Elem: &schema.Schema{Type: schema.TypeString}, }, "assume_role_tags": { Type: schema.TypeMap, Optional: true, Description: "Assume role session tags.", Elem: &schema.Schema{Type: schema.TypeString}, }, "assume_role_transitive_tag_keys": { Type: schema.TypeSet, Optional: true, Description: "Assume role session tag keys to pass to any subsequent sessions.", Elem: &schema.Schema{Type: schema.TypeString}, }, "workspace_key_prefix": { Type: schema.TypeString, Optional: true, Description: "The prefix applied to the non-default state path inside the bucket.", Default: "env:", ValidateFunc: func(v interface{}, s string) ([]string, []error) { prefix := v.(string) if strings.HasPrefix(prefix, "/") || strings.HasSuffix(prefix, "/") { return nil, []error{errors.New("workspace_key_prefix must not start or end with '/'")} } return nil, nil }, }, "force_path_style": { Type: schema.TypeBool, Optional: true, Description: "Force s3 to use path style api.", Default: false, }, "max_retries": { Type: schema.TypeInt, Optional: true, Description: "The maximum number of times an AWS API request is retried on retryable failure.", Default: 5, }, }, } result := &Backend{Backend: s} result.Backend.ConfigureFunc = result.configure return result } type Backend struct { *schema.Backend // The fields below are set from configure s3Client *s3.S3 dynClient *dynamodb.DynamoDB bucketName string keyName string serverSideEncryption bool customerEncryptionKey []byte acl string kmsKeyID string ddbTable string workspaceKeyPrefix string } func (b *Backend) configure(ctx context.Context) error { if b.s3Client != nil { return nil } // Grab the resource data data := schema.FromContextBackendConfig(ctx) if !data.Get("skip_region_validation").(bool) { if err := awsbase.ValidateRegion(data.Get("region").(string)); err != nil { return err } } b.bucketName = data.Get("bucket").(string) b.keyName = data.Get("key").(string) b.acl = data.Get("acl").(string) b.workspaceKeyPrefix = data.Get("workspace_key_prefix").(string) b.serverSideEncryption = data.Get("encrypt").(bool) b.kmsKeyID = data.Get("kms_key_id").(string) b.ddbTable = data.Get("dynamodb_table").(string) customerKeyString := data.Get("sse_customer_key").(string) if customerKeyString != "" { if b.kmsKeyID != "" { return errors.New(encryptionKeyConflictError) } var err error b.customerEncryptionKey, err = base64.StdEncoding.DecodeString(customerKeyString) if err != nil { return fmt.Errorf("Failed to decode sse_customer_key: %s", err.Error()) } } cfg := &awsbase.Config{ AccessKey: data.Get("access_key").(string), AssumeRoleARN: data.Get("role_arn").(string), AssumeRoleDurationSeconds: data.Get("assume_role_duration_seconds").(int), AssumeRoleExternalID: data.Get("external_id").(string), AssumeRolePolicy: data.Get("assume_role_policy").(string), AssumeRoleSessionName: data.Get("session_name").(string), CallerDocumentationURL: "https://www.terraform.io/docs/backends/types/s3.html", CallerName: "S3 Backend", CredsFilename: data.Get("shared_credentials_file").(string), DebugLogging: logging.IsDebugOrHigher(), IamEndpoint: data.Get("iam_endpoint").(string), MaxRetries: data.Get("max_retries").(int), Profile: data.Get("profile").(string), Region: data.Get("region").(string), SecretKey: data.Get("secret_key").(string), SkipCredsValidation: data.Get("skip_credentials_validation").(bool), SkipMetadataApiCheck: data.Get("skip_metadata_api_check").(bool), StsEndpoint: data.Get("sts_endpoint").(string), Token: data.Get("token").(string), UserAgentProducts: []*awsbase.UserAgentProduct{ {Name: "APN", Version: "1.0"}, {Name: "HashiCorp", Version: "1.0"}, {Name: "Terraform", Version: version.String()}, }, } if policyARNSet := data.Get("assume_role_policy_arns").(*schema.Set); policyARNSet.Len() > 0 { for _, policyARNRaw := range policyARNSet.List() { policyARN, ok := policyARNRaw.(string) if !ok { continue } cfg.AssumeRolePolicyARNs = append(cfg.AssumeRolePolicyARNs, policyARN) } } if tagMap := data.Get("assume_role_tags").(map[string]interface{}); len(tagMap) > 0 { cfg.AssumeRoleTags = make(map[string]string) for k, vRaw := range tagMap { v, ok := vRaw.(string) if !ok { continue } cfg.AssumeRoleTags[k] = v } } if transitiveTagKeySet := data.Get("assume_role_transitive_tag_keys").(*schema.Set); transitiveTagKeySet.Len() > 0 { for _, transitiveTagKeyRaw := range transitiveTagKeySet.List() { transitiveTagKey, ok := transitiveTagKeyRaw.(string) if !ok { continue } cfg.AssumeRoleTransitiveTagKeys = append(cfg.AssumeRoleTransitiveTagKeys, transitiveTagKey) } } sess, err := awsbase.GetSession(cfg) if err != nil { return fmt.Errorf("error configuring S3 Backend: %w", err) } b.dynClient = dynamodb.New(sess.Copy(&aws.Config{ Endpoint: aws.String(data.Get("dynamodb_endpoint").(string)), })) b.s3Client = s3.New(sess.Copy(&aws.Config{ Endpoint: aws.String(data.Get("endpoint").(string)), S3ForcePathStyle: aws.Bool(data.Get("force_path_style").(bool)), })) return nil } const encryptionKeyConflictError = `Cannot have both kms_key_id and sse_customer_key set. The kms_key_id is used for encryption with KMS-Managed Keys (SSE-KMS) while sse_customer_key is used for encryption with customer-managed keys (SSE-C). Please choose one or the other.`