diff --git a/docs/setup.rst b/docs/setup.rst index 3da070a8..5f6be6c9 100644 --- a/docs/setup.rst +++ b/docs/setup.rst @@ -291,13 +291,25 @@ Required fields: +-----------+-----------------------------------------------+-----------------------+-----------------------------------+ | Region | Region name | Yes | eu-central-1 | +-----------+-----------------------------------------------+-----------------------+-----------------------------------+ -| KeyId | API key ID | Yes | keyname123456789 | +| KeyId | API key ID | For static keys | keyname123456789 | +-----------+-----------------------------------------------+-----------------------+-----------------------------------+ -| KeySecret | API key secret | Yes | verysecret123 | +| KeySecret | API key secret | For static keys | verysecret123 | +-----------+-----------------------------------------------+-----------------------+-----------------------------------+ | Endpoint | Custom endpoint. Leave blank for AWS S3. | Only for Backblaze B2 | s3.eu-central-001.backblazeb2.com | +-----------+-----------------------------------------------+-----------------------+-----------------------------------+ +To use an IAM role or the AWS SDK credential provider chain, leave **both** +``KeyId`` and ``KeySecret`` blank. Bucket and region are still required. This +works with the setup form, ``cloudconfig.yml``, or ``GOKAPI_AWS_BUCKET`` and +``GOKAPI_AWS_REGION`` environment variables. Providing only one key is invalid. + +The SDK can obtain temporary credentials from an EC2 instance profile, an ECS +task role, EKS web identity, a shared credentials file, or standard AWS +``AWS_ACCESS_KEY_ID``, ``AWS_SECRET_ACCESS_KEY``, and ``AWS_SESSION_TOKEN`` +environment variables. Explicit Gokapi keys keep their existing precedence. +Grant the role access to the bucket and use **Test configuration** before +finishing setup. Gokapi does not write resolved role credentials to its config. + If you plan to use end-to-end encryption with cloud storage, configure your bucket's CORS rules to allow requests from your Gokapi URL. .. _setup_encryption: diff --git a/internal/configuration/setup/Setup.go b/internal/configuration/setup/Setup.go index 16b19f6c..46e3f00a 100644 --- a/internal/configuration/setup/Setup.go +++ b/internal/configuration/setup/Setup.go @@ -588,6 +588,9 @@ func getCloudConfig(formObjects *[]jsonFormObject) (*cloudconfig.CloudConfig, er if err != nil { return nil, err } + if (awsConfig.Aws.KeyId == "") != (awsConfig.Aws.KeySecret == "") { + return nil, errors.New("provide both S3 key fields, or leave both blank to use the AWS credential provider chain") + } return &awsConfig, nil } diff --git a/internal/configuration/setup/Setup_test.go b/internal/configuration/setup/Setup_test.go index 8100e7aa..fbde9d59 100644 --- a/internal/configuration/setup/Setup_test.go +++ b/internal/configuration/setup/Setup_test.go @@ -757,3 +757,24 @@ func TestIsErrorAddressAlreadyInUse(t *testing.T) { test.IsEqualBool(t, isErrorAddressAlreadyInUse(err), false) l.Close() } + +func TestCloudConfigCredentialModes(t *testing.T) { + for _, tc := range []struct { + key, secret string + valid bool + }{ + {"", "", true}, {"key", "secret", true}, {"key", "", false}, {"", "secret", false}, + } { + form := []jsonFormObject{ + {Name: "storage_sel_proxy", Value: "proxy"}, + {Name: "s3_bucket", Value: "bucket"}, {Name: "s3_region", Value: "region"}, + {Name: "s3_api", Value: tc.key}, {Name: "s3_secret", Value: tc.secret}, + {Name: "s3_endpoint", Value: ""}, + } + config, err := getCloudConfig(&form) + test.IsEqualBool(t, err == nil, tc.valid) + if tc.valid { + test.IsEqualBool(t, config.Aws.IsAllProvided(), true) + } + } +} diff --git a/internal/configuration/setup/templates/setup.tmpl b/internal/configuration/setup/templates/setup.tmpl index a42f205c..9fffdae6 100644 --- a/internal/configuration/setup/templates/setup.tmpl +++ b/internal/configuration/setup/templates/setup.tmpl @@ -462,13 +462,14 @@

+

Leave both key fields blank to use the AWS credential provider chain (for example, an IAM role). Otherwise, provide both fields.

- +


- +


@@ -918,6 +919,13 @@ function TestAWS(button, isManual) { function parseAndSetDbString(input) { } + function validateS3Credentials(el) { + return { + status: Boolean(document.getElementById("s3_api").value) === Boolean(document.getElementById("s3_secret").value), + msg: "Provide both key fields, or leave both blank to use the AWS credential provider chain" + }; + } + function validateMinLength(el) { let value = el.val(); let retValue = {}; diff --git a/internal/environment/Environment.go b/internal/environment/Environment.go index 3371afc4..1a605406 100644 --- a/internal/environment/Environment.go +++ b/internal/environment/Environment.go @@ -247,8 +247,7 @@ func parseFlags(result Environment) Environment { func (e *Environment) IsAwsProvided() bool { return e.AwsBucket != "" && e.AwsRegion != "" && - e.AwsKeyId != "" && - e.AwsKeySecret != "" + ((e.AwsKeyId == "" && e.AwsKeySecret == "") || (e.AwsKeyId != "" && e.AwsKeySecret != "")) } // GetConfigPaths returns the config paths to config files and the directory containing the files. The following results are returned: diff --git a/internal/environment/Environment_test.go b/internal/environment/Environment_test.go index 06ba9515..adc54fe6 100644 --- a/internal/environment/Environment_test.go +++ b/internal/environment/Environment_test.go @@ -86,3 +86,19 @@ func TestGetConfigPaths(t *testing.T) { test.IsEqualString(t, configFile, "test2") test.IsEqualString(t, awsConfig, "test/cloudconfig.yml") } + +func TestAwsEnvironmentCredentialModes(t *testing.T) { + for _, tc := range []struct { + key, secret string + valid bool + }{ + {"", "", true}, {"key", "secret", true}, {"key", "", false}, {"", "secret", false}, + } { + t.Setenv("GOKAPI_AWS_BUCKET", "bucket") + t.Setenv("GOKAPI_AWS_REGION", "region") + t.Setenv("GOKAPI_AWS_KEY", tc.key) + t.Setenv("GOKAPI_AWS_KEY_SECRET", tc.secret) + env := New() + test.IsEqualBool(t, env.IsAwsProvided(), tc.valid) + } +} diff --git a/internal/models/AwsConfig.go b/internal/models/AwsConfig.go index 4a211f5a..0e2d1ce0 100644 --- a/internal/models/AwsConfig.go +++ b/internal/models/AwsConfig.go @@ -14,6 +14,5 @@ type AwsConfig struct { func (c *AwsConfig) IsAllProvided() bool { return c.Bucket != "" && c.Region != "" && - c.KeyId != "" && - c.KeySecret != "" + ((c.KeyId == "" && c.KeySecret == "") || (c.KeyId != "" && c.KeySecret != "")) } diff --git a/internal/models/AwsConfig_test.go b/internal/models/AwsConfig_test.go index e5076a1f..f8493058 100644 --- a/internal/models/AwsConfig_test.go +++ b/internal/models/AwsConfig_test.go @@ -17,3 +17,17 @@ func TestIsAwsProvided(t *testing.T) { } test.IsEqualBool(t, config.IsAllProvided(), true) } + +func TestAwsCredentialModes(t *testing.T) { + for _, tc := range []struct { + key, secret string + valid bool + }{ + {"", "", true}, {"key", "secret", true}, {"key", "", false}, {"", "secret", false}, + } { + config := AwsConfig{Bucket: "bucket", Region: "region", KeyId: tc.key, KeySecret: tc.secret} + test.IsEqualBool(t, config.IsAllProvided(), tc.valid) + config.Region = "" + test.IsEqualBool(t, config.IsAllProvided(), false) + } +} diff --git a/internal/storage/filesystem/s3filesystem/aws/Aws.go b/internal/storage/filesystem/s3filesystem/aws/Aws.go index 01758390..d558d859 100644 --- a/internal/storage/filesystem/s3filesystem/aws/Aws.go +++ b/internal/storage/filesystem/s3filesystem/aws/Aws.go @@ -84,11 +84,14 @@ func IsValidLogin(config models.AwsConfig) (bool, error) { func createSession() *session.Session { s3Config := &aws.Config{ - Credentials: credentials.NewStaticCredentials(awsConfig.KeyId, awsConfig.KeySecret, ""), Endpoint: aws.String(awsConfig.Endpoint), Region: aws.String(awsConfig.Region), S3ForcePathStyle: aws.Bool(true), } + // Leaving Credentials unset enables the SDK provider chain, including IAM roles. + if awsConfig.KeyId != "" || awsConfig.KeySecret != "" { + s3Config.Credentials = credentials.NewStaticCredentials(awsConfig.KeyId, awsConfig.KeySecret, "") + } return session.Must(session.NewSession(s3Config)) } diff --git a/internal/storage/filesystem/s3filesystem/aws/Credentials_test.go b/internal/storage/filesystem/s3filesystem/aws/Credentials_test.go new file mode 100644 index 00000000..a144ca94 --- /dev/null +++ b/internal/storage/filesystem/s3filesystem/aws/Credentials_test.go @@ -0,0 +1,36 @@ +//go:build !noaws && !awsmock + +package aws + +import ( + "github.com/forceu/gokapi/internal/models" + "testing" +) + +func TestSessionCredentials(t *testing.T) { + previous := awsConfig + t.Cleanup(func() { awsConfig = previous }) + t.Setenv("AWS_ACCESS_KEY_ID", "environment-key") + t.Setenv("AWS_SECRET_ACCESS_KEY", "environment-secret") + t.Setenv("AWS_SESSION_TOKEN", "temporary-token") + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") + for _, static := range []bool{false, true} { + awsConfig = models.AwsConfig{Bucket: "bucket", Region: "eu-central-1"} + if static { + awsConfig.KeyId = "static-key" + awsConfig.KeySecret = "static-secret" + } + sess := createSession() + value, err := sess.Config.Credentials.Get() + if err != nil { + t.Fatal(err) + } + if static { + if value.AccessKeyID != "static-key" || value.SecretAccessKey != "static-secret" || value.SessionToken != "" { + t.Fatalf("static credentials not preserved: %s", value.ProviderName) + } + } else if value.AccessKeyID != "environment-key" || value.SecretAccessKey != "environment-secret" || value.SessionToken != "temporary-token" { + t.Fatalf("default credential chain not used: %s", value.ProviderName) + } + } +}