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 @@
-
+
@@ -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)
+ }
+ }
+}