You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
terraform/internal/backend/remote-state/s3/backend.go

561 lines
17 KiB

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package s3
import (
"encoding/base64"
"fmt"
"os"
"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/internal/backend"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/logging"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/hashicorp/terraform/version"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/gocty"
)
func New() backend.Backend {
return &Backend{}
}
type Backend struct {
s3Client *s3.S3
dynClient *dynamodb.DynamoDB
bucketName string
keyName string
serverSideEncryption bool
customerEncryptionKey []byte
acl string
kmsKeyID string
ddbTable string
workspaceKeyPrefix string
}
// ConfigSchema returns a description of the expected configuration
// structure for the receiving backend.
func (b *Backend) ConfigSchema() *configschema.Block {
return &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"bucket": {
Type: cty.String,
Required: true,
Description: "The name of the S3 bucket",
},
"key": {
Type: cty.String,
Required: true,
Description: "The path to the state file inside the bucket",
},
"region": {
Type: cty.String,
Optional: true,
Description: "AWS region of the S3 Bucket and DynamoDB Table (if used).",
},
"dynamodb_endpoint": {
Type: cty.String,
Optional: true,
Description: "A custom endpoint for the DynamoDB API",
},
"endpoint": {
Type: cty.String,
Optional: true,
Description: "A custom endpoint for the S3 API",
},
"iam_endpoint": {
Type: cty.String,
Optional: true,
Description: "A custom endpoint for the IAM API",
},
"sts_endpoint": {
Type: cty.String,
Optional: true,
Description: "A custom endpoint for the STS API",
},
"encrypt": {
Type: cty.Bool,
Optional: true,
Description: "Whether to enable server side encryption of the state file",
},
"acl": {
Type: cty.String,
Optional: true,
Description: "Canned ACL to be applied to the state file",
},
"access_key": {
Type: cty.String,
Optional: true,
Description: "AWS access key",
},
"secret_key": {
Type: cty.String,
Optional: true,
Description: "AWS secret key",
},
"kms_key_id": {
Type: cty.String,
Optional: true,
Description: "The ARN of a KMS Key to use for encrypting the state",
},
"dynamodb_table": {
Type: cty.String,
Optional: true,
Description: "DynamoDB table for state locking and consistency",
},
"profile": {
Type: cty.String,
Optional: true,
Description: "AWS profile name",
},
"shared_credentials_file": {
Type: cty.String,
Optional: true,
Description: "Path to a shared credentials file",
},
"token": {
Type: cty.String,
Optional: true,
Description: "MFA token",
},
"skip_credentials_validation": {
Type: cty.Bool,
Optional: true,
Description: "Skip the credentials validation via STS API.",
},
"skip_metadata_api_check": {
Type: cty.Bool,
Optional: true,
Description: "Skip the AWS Metadata API check.",
},
"skip_region_validation": {
Type: cty.Bool,
Optional: true,
Description: "Skip static validation of region name.",
},
"sse_customer_key": {
Type: cty.String,
Optional: true,
Description: "The base64-encoded encryption key to use for server-side encryption with customer-provided keys (SSE-C).",
Sensitive: true,
},
"role_arn": {
Type: cty.String,
Optional: true,
Description: "The role to be assumed",
},
"session_name": {
Type: cty.String,
Optional: true,
Description: "The session name to use when assuming the role.",
},
"external_id": {
Type: cty.String,
Optional: true,
Description: "The external ID to use when assuming the role",
},
"assume_role_duration_seconds": {
Type: cty.Number,
Optional: true,
Description: "Seconds to restrict the assume role session duration.",
},
"assume_role_policy": {
Type: cty.String,
Optional: true,
Description: "IAM Policy JSON describing further restricting permissions for the IAM Role being assumed.",
},
"assume_role_policy_arns": {
Type: cty.Set(cty.String),
Optional: true,
Description: "Amazon Resource Names (ARNs) of IAM Policies describing further restricting permissions for the IAM Role being assumed.",
},
"assume_role_tags": {
Type: cty.Map(cty.String),
Optional: true,
Description: "Assume role session tags.",
},
"assume_role_transitive_tag_keys": {
Type: cty.Set(cty.String),
Optional: true,
Description: "Assume role session tag keys to pass to any subsequent sessions.",
},
"workspace_key_prefix": {
Type: cty.String,
Optional: true,
Description: "The prefix applied to the non-default state path inside the bucket.",
},
"force_path_style": {
Type: cty.Bool,
Optional: true,
Description: "Force s3 to use path style api.",
},
"max_retries": {
Type: cty.Number,
Optional: true,
Description: "The maximum number of times an AWS API request is retried on retryable failure.",
},
},
}
}
// PrepareConfig checks the validity of the values in the given
// configuration, and inserts any missing defaults, assuming that its
// structure has already been validated per the schema returned by
// ConfigSchema.
func (b *Backend) PrepareConfig(obj cty.Value) (cty.Value, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
if obj.IsNull() {
return obj, diags
}
if val := obj.GetAttr("bucket"); val.IsNull() || val.AsString() == "" {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid bucket value",
`The "bucket" attribute value must not be empty.`,
cty.Path{cty.GetAttrStep{Name: "bucket"}},
))
}
if val := obj.GetAttr("key"); val.IsNull() || val.AsString() == "" {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid key value",
`The "key" attribute value must not be empty.`,
cty.Path{cty.GetAttrStep{Name: "key"}},
))
} else if strings.HasPrefix(val.AsString(), "/") || strings.HasSuffix(val.AsString(), "/") {
// S3 will strip leading slashes from an object, so while this will
// technically be accepted by S3, it will break our workspace hierarchy.
// S3 will recognize objects with a trailing slash as a directory
// so they should not be valid keys
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid key value",
`The "key" attribute value must not start or end with with "/".`,
cty.Path{cty.GetAttrStep{Name: "key"}},
))
}
if val := obj.GetAttr("region"); val.IsNull() || val.AsString() == "" {
if os.Getenv("AWS_REGION") == "" && os.Getenv("AWS_DEFAULT_REGION") == "" {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Missing region value",
`The "region" attribute or the "AWS_REGION" or "AWS_DEFAULT_REGION" environment variables must be set.`,
cty.Path{cty.GetAttrStep{Name: "region"}},
))
}
}
if val := obj.GetAttr("kms_key_id"); !val.IsNull() && val.AsString() != "" {
if val := obj.GetAttr("sse_customer_key"); !val.IsNull() && val.AsString() != "" {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid encryption configuration",
encryptionKeyConflictError,
cty.Path{},
))
} else if customerKey := os.Getenv("AWS_SSE_CUSTOMER_KEY"); customerKey != "" {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid encryption configuration",
encryptionKeyConflictEnvVarError,
cty.Path{},
))
}
diags = diags.Append(validateKMSKey(cty.Path{cty.GetAttrStep{Name: "kms_key_id"}}, val.AsString()))
}
if val := obj.GetAttr("workspace_key_prefix"); !val.IsNull() {
if v := val.AsString(); strings.HasPrefix(v, "/") || strings.HasSuffix(v, "/") {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid workspace_key_prefix value",
`The "workspace_key_prefix" attribute value must not start with "/".`,
cty.Path{cty.GetAttrStep{Name: "workspace_key_prefix"}},
))
}
}
return obj, diags
}
// Configure uses the provided configuration to set configuration fields
// within the backend.
//
// The given configuration is assumed to have already been validated
// against the schema returned by ConfigSchema and passed validation
// via PrepareConfig.
func (b *Backend) Configure(obj cty.Value) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
if obj.IsNull() {
return diags
}
var region string
if v, ok := stringAttrOk(obj, "region"); ok {
region = v
}
if region != "" && !boolAttr(obj, "skip_region_validation") {
if err := awsbase.ValidateRegion(region); err != nil {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid region value",
err.Error(),
cty.Path{cty.GetAttrStep{Name: "region"}},
))
return diags
}
}
b.bucketName = stringAttr(obj, "bucket")
b.keyName = stringAttr(obj, "key")
b.acl = stringAttr(obj, "acl")
b.workspaceKeyPrefix = stringAttrDefault(obj, "workspace_key_prefix", "env:")
b.serverSideEncryption = boolAttr(obj, "encrypt")
b.kmsKeyID = stringAttr(obj, "kms_key_id")
b.ddbTable = stringAttr(obj, "dynamodb_table")
if customerKey, ok := stringAttrOk(obj, "sse_customer_key"); ok {
if len(customerKey) != 44 {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid sse_customer_key value",
"sse_customer_key must be 44 characters in length",
cty.Path{cty.GetAttrStep{Name: "sse_customer_key"}},
))
} else {
var err error
if b.customerEncryptionKey, err = base64.StdEncoding.DecodeString(customerKey); err != nil {
diags = diags.Append(tfdiags.AttributeValue(
tfdiags.Error,
"Invalid sse_customer_key value",
fmt.Sprintf("sse_customer_key must be base64 encoded: %s", err),
cty.Path{cty.GetAttrStep{Name: "sse_customer_key"}},
))
}
}
} else if customerKey := os.Getenv("AWS_SSE_CUSTOMER_KEY"); customerKey != "" {
if len(customerKey) != 44 {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Invalid AWS_SSE_CUSTOMER_KEY value",
`The environment variable "AWS_SSE_CUSTOMER_KEY" must be 44 characters in length`,
))
} else {
var err error
if b.customerEncryptionKey, err = base64.StdEncoding.DecodeString(customerKey); err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Invalid AWS_SSE_CUSTOMER_KEY value",
fmt.Sprintf(`The environment variable "AWS_SSE_CUSTOMER_KEY" must be base64 encoded: %s`, err),
))
}
}
}
cfg := &awsbase.Config{
AccessKey: stringAttr(obj, "access_key"),
AssumeRoleARN: stringAttr(obj, "role_arn"),
AssumeRoleDurationSeconds: intAttr(obj, "assume_role_duration_seconds"),
AssumeRoleExternalID: stringAttr(obj, "external_id"),
AssumeRolePolicy: stringAttr(obj, "assume_role_policy"),
AssumeRoleSessionName: stringAttr(obj, "session_name"),
CallerDocumentationURL: "https://www.terraform.io/docs/language/settings/backends/s3.html",
CallerName: "S3 Backend",
CredsFilename: stringAttr(obj, "shared_credentials_file"),
DebugLogging: logging.IsDebugOrHigher(),
IamEndpoint: stringAttrDefaultEnvVar(obj, "iam_endpoint", "AWS_IAM_ENDPOINT"),
MaxRetries: intAttrDefault(obj, "max_retries", 5),
Profile: stringAttr(obj, "profile"),
Region: stringAttr(obj, "region"),
SecretKey: stringAttr(obj, "secret_key"),
SkipCredsValidation: boolAttr(obj, "skip_credentials_validation"),
SkipMetadataApiCheck: boolAttr(obj, "skip_metadata_api_check"),
StsEndpoint: stringAttrDefaultEnvVar(obj, "sts_endpoint", "AWS_STS_ENDPOINT"),
Token: stringAttr(obj, "token"),
UserAgentProducts: []*awsbase.UserAgentProduct{
{Name: "APN", Version: "1.0"},
{Name: "HashiCorp", Version: "1.0"},
{Name: "Terraform", Version: version.String()},
},
}
if policyARNSet := obj.GetAttr("assume_role_policy_arns"); !policyARNSet.IsNull() {
policyARNSet.ForEachElement(func(key, val cty.Value) (stop bool) {
v, ok := stringValueOk(val)
if ok {
cfg.AssumeRolePolicyARNs = append(cfg.AssumeRolePolicyARNs, v)
}
return
})
}
if tagMap := obj.GetAttr("assume_role_tags"); !tagMap.IsNull() {
cfg.AssumeRoleTags = make(map[string]string, tagMap.LengthInt())
tagMap.ForEachElement(func(key, val cty.Value) (stop bool) {
k := stringValue(key)
v, ok := stringValueOk(val)
if ok {
cfg.AssumeRoleTags[k] = v
}
return
})
}
if transitiveTagKeySet := obj.GetAttr("assume_role_transitive_tag_keys"); !transitiveTagKeySet.IsNull() {
transitiveTagKeySet.ForEachElement(func(key, val cty.Value) (stop bool) {
v, ok := stringValueOk(val)
if ok {
cfg.AssumeRoleTransitiveTagKeys = append(cfg.AssumeRoleTransitiveTagKeys, v)
}
return
})
}
sess, err := awsbase.GetSession(cfg)
if err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to configure AWS client",
fmt.Sprintf(`The "S3" backend encountered an unexpected error while creating the AWS client: %s`, err),
))
return diags
}
var dynamoConfig aws.Config
if v, ok := stringAttrDefaultEnvVarOk(obj, "dynamodb_endpoint", "AWS_DYNAMODB_ENDPOINT"); ok {
dynamoConfig.Endpoint = aws.String(v)
}
b.dynClient = dynamodb.New(sess.Copy(&dynamoConfig))
var s3Config aws.Config
if v, ok := stringAttrDefaultEnvVarOk(obj, "endpoint", "AWS_S3_ENDPOINT"); ok {
s3Config.Endpoint = aws.String(v)
}
if v, ok := boolAttrOk(obj, "force_path_style"); ok {
s3Config.S3ForcePathStyle = aws.Bool(v)
}
b.s3Client = s3.New(sess.Copy(&s3Config))
return diags
}
func stringValue(val cty.Value) string {
v, _ := stringValueOk(val)
return v
}
func stringValueOk(val cty.Value) (string, bool) {
if val.IsNull() {
return "", false
} else {
return val.AsString(), true
}
}
func stringAttr(obj cty.Value, name string) string {
return stringValue(obj.GetAttr(name))
}
func stringAttrOk(obj cty.Value, name string) (string, bool) {
return stringValueOk(obj.GetAttr(name))
}
func stringAttrDefault(obj cty.Value, name, def string) string {
if v, ok := stringAttrOk(obj, name); !ok {
return def
} else {
return v
}
}
func stringAttrDefaultEnvVar(obj cty.Value, name string, envvars ...string) string {
if v, ok := stringAttrDefaultEnvVarOk(obj, name, envvars...); !ok {
return ""
} else {
return v
}
}
func stringAttrDefaultEnvVarOk(obj cty.Value, name string, envvars ...string) (string, bool) {
if v, ok := stringAttrOk(obj, name); !ok {
for _, envvar := range envvars {
if v := os.Getenv(envvar); v != "" {
return v, true
}
}
return "", false
} else {
return v, true
}
}
func boolAttr(obj cty.Value, name string) bool {
v, _ := boolAttrOk(obj, name)
return v
}
func boolAttrOk(obj cty.Value, name string) (bool, bool) {
if val := obj.GetAttr(name); val.IsNull() {
return false, false
} else {
return val.True(), true
}
}
func intAttr(obj cty.Value, name string) int {
v, _ := intAttrOk(obj, name)
return v
}
func intAttrOk(obj cty.Value, name string) (int, bool) {
if val := obj.GetAttr(name); val.IsNull() {
return 0, false
} else {
var v int
if err := gocty.FromCtyValue(val, &v); err != nil {
return 0, false
}
return v, true
}
}
func intAttrDefault(obj cty.Value, name string, def int) int {
if v, ok := intAttrOk(obj, name); !ok {
return def
} else {
return v
}
}
const encryptionKeyConflictError = `Only one of "kms_key_id" and "sse_customer_key" can be 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.`
const encryptionKeyConflictEnvVarError = `Only one of "kms_key_id" and the environment variable "AWS_SSE_CUSTOMER_KEY" can be set.
The "kms_key_id" is used for encryption with KMS-Managed Keys (SSE-KMS)
while "AWS_SSE_CUSTOMER_KEY" is used for encryption with customer-managed keys (SSE-C).
Please choose one or the other.`