feat(storage): support AWS S3 NoAk default credentials (#2008)

* feat(storage): support AWS S3 default credentials

* fix(storage): allow clearing S3 credentials

* fix static checks and trim locale diff

---------

Co-authored-by: qilifan <4359902+qilifan@users.noreply.github.com>
This commit is contained in:
qilifan
2026-08-05 11:59:02 +08:00
committed by GitHub
parent 350d9e4eca
commit 3507156929
15 changed files with 151 additions and 34 deletions
+6 -1
View File
@@ -200,11 +200,16 @@ LOCAL_STORAGE_BASE_DIR=/data/files
# TOS_PATH_PREFIX=your_tos_path_prefix
# TOS_TEMP_BUCKET_NAME=your_tos_temp_bucket_name
# TOS_TEMP_REGION=your_tos_temp_region
# 如果使用AWS S3作为文件存储,需要配置以下参数
# AWS S3的访问端点(可选;留空使用 Region 对应的 AWS 标准端点,例如 https://s3.amazonaws.com
# ----- AWS S3STORAGE_TYPE=s3-----
# S3_ENDPOINT=https://s3.amazonaws.com
# S3_REGION=us-east-1
# S3_ACCESS_KEY=your_s3_access_key
# AWS S3访问密钥 Access Key(可选;AK/SK 同时留空时使用 AWS SDK 默认凭证链,
# 支持 EC2/ECS/EKS IAM Role、IRSA/Web Identity、环境变量和共享配置文件)
# AWS S3访问密钥 Secret Key(必须与 Access Key 同时填写或同时留空)
# S3_SECRET_KEY=your_s3_secret_key
# S3_ACCESS_KEY=your_s3_access_key
# S3_BUCKET_NAME=your_s3_bucket_name
# S3_PATH_PREFIX=weknora/
# S3_USE_SSL=true
+2 -2
View File
@@ -180,9 +180,9 @@ export interface StorageEngineConfig {
path_prefix: string
}
s3: {
endpoint: string
endpoint: string // optional for standard AWS S3
region: string
access_key: string
access_key: string // both keys empty => AWS default credential chain
secret_key: string
bucket_name: string
path_prefix: string
+2
View File
@@ -1244,6 +1244,8 @@ export default {
s3Desc: 'AWS S3 and S3-compatible object storage services, suitable for public cloud deployment.',
s3AccessKeyPlaceholder: 'AWS Access Key',
s3SecretKeyPlaceholder: 'AWS Secret Key',
s3DefaultCredentialsHint: 'Leave both keys empty to use the AWS default credential chain (IAM role, IRSA / web identity, environment, or shared config).',
s3EndpointPlaceholder: 'Optional; leave empty to use the AWS regional endpoint',
ks3Title: 'Kingsoft Cloud KS3',
ks3Desc: 'Kingsoft Cloud Object Storage Service (KS3), suitable for public cloud deployment.',
ks3AccessKeyPlaceholder: 'Kingsoft Cloud Access Key',
+2
View File
@@ -4613,6 +4613,8 @@ export default {
s3Desc: 'AWS S3 및 호환 오브젝트 스토리지 서비스, 퍼블릭 클라우드 배포에 적합합니다.',
s3AccessKeyPlaceholder: 'AWS Access Key',
s3SecretKeyPlaceholder: 'AWS Secret Key',
s3DefaultCredentialsHint: '두 키를 모두 비워 두면 AWS 기본 자격 증명 체인(IAM 역할, IRSA / 웹 자격 증명, 환경 변수 또는 공유 구성)을 사용합니다.',
s3EndpointPlaceholder: '선택 사항; 비워 두면 AWS 리전 기본 엔드포인트 사용',
ks3Title: 'Kingsoft Cloud KS3',
ks3Desc: 'Kingsoft Cloud 오브젝트 스토리지 서비스(KS3), 퍼블릭 클라우드 배포에 적합합니다.',
ks3AccessKeyPlaceholder: 'Kingsoft Cloud Access Key',
+2
View File
@@ -4613,6 +4613,8 @@ export default {
s3Desc: 'AWS S3 и совместимые сервисы объектного хранилища для публичного облака.',
s3AccessKeyPlaceholder: 'AWS Access Key',
s3SecretKeyPlaceholder: 'AWS Secret Key',
s3DefaultCredentialsHint: 'Оставьте оба ключа пустыми, чтобы использовать стандартную цепочку учётных данных AWS (IAM role, IRSA / web identity, переменные среды или общий профиль).',
s3EndpointPlaceholder: 'Необязательно; оставьте пустым для регионального endpoint AWS',
ks3Title: 'Kingsoft Cloud KS3',
ks3Desc: 'Объектное хранилище Kingsoft Cloud (KS3), подходит для публичного облака.',
ks3AccessKeyPlaceholder: 'Kingsoft Cloud Access Key',
+2
View File
@@ -4613,6 +4613,8 @@ export default {
s3Desc: 'AWS S3 及兼容的对象存储服务,适合公有云部署。',
s3AccessKeyPlaceholder: 'AWS Access Key',
s3SecretKeyPlaceholder: 'AWS Secret Key',
s3DefaultCredentialsHint: 'Access Key 与 Secret Key 同时留空时,将使用 AWS 默认凭证链(IAM Role、IRSA / Web Identity、环境变量或共享配置)。',
s3EndpointPlaceholder: '可选,留空使用 AWS 区域默认端点',
ks3Title: '金山云 KS3',
ks3Desc: '金山云对象存储服务(KS3),适合公有云部署。',
ks3AccessKeyPlaceholder: '金山云 Access Key',
@@ -369,14 +369,15 @@
<template v-else-if="currentEngine === 's3'">
<section class="setting-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('settings.storage.credentialsSection', '凭证') }}</h4>
<p class="form-desc">{{ $t('settings.storage.s3DefaultCredentialsHint') }}</p>
<div class="form-item">
<label class="form-label required">Access Key</label>
<label class="form-label">Access Key</label>
<t-input v-model="config.s3.access_key" :placeholder="$t('settings.storage.s3AccessKeyPlaceholder')" clearable>
<template #prefix-icon><t-icon name="lock-on" /></template>
</t-input>
</div>
<div class="form-item">
<label class="form-label required">Secret Key</label>
<label class="form-label">Secret Key</label>
<t-input v-model="config.s3.secret_key" type="password" :placeholder="$t('settings.storage.s3SecretKeyPlaceholder')" clearable>
<template #prefix-icon><t-icon name="lock-on" /></template>
</t-input>
@@ -385,8 +386,8 @@
<section class="setting-drawer__section">
<h4 class="setting-drawer__section-title">{{ $t('settings.storage.bucketSection', 'Bucket') }}</h4>
<div class="form-item">
<label class="form-label required">Endpoint</label>
<t-input v-model="config.s3.endpoint" placeholder="e.g. https://s3.amazonaws.com" clearable />
<label class="form-label">Endpoint</label>
<t-input v-model="config.s3.endpoint" :placeholder="$t('settings.storage.s3EndpointPlaceholder')" clearable />
</div>
<div class="form-item">
<label class="form-label required">Region</label>
@@ -1684,4 +1685,3 @@ onMounted(loadAll)
color: #CE1126;
}
</style>
+1 -1
View File
@@ -92,7 +92,7 @@ func NewFileServiceFromStorageConfig(
svc, err := NewTosFileServiceWithTempBucket(sec.TOS.Endpoint, sec.TOS.Region, sec.TOS.AccessKey, sec.TOS.SecretKey, sec.TOS.BucketName, sec.TOS.PathPrefix, sec.TOS.TempBucketName, sec.TOS.TempRegion)
return svc, p, err
case "s3":
if sec == nil || sec.S3 == nil || sec.S3.Endpoint == "" || sec.S3.Region == "" || sec.S3.AccessKey == "" || sec.S3.SecretKey == "" || sec.S3.BucketName == "" {
if sec == nil || sec.S3 == nil || sec.S3.Region == "" || sec.S3.BucketName == "" || (sec.S3.AccessKey == "") != (sec.S3.SecretKey == "") {
return nil, p, fmt.Errorf("incomplete s3 config")
}
pathPrefix := strings.TrimSpace(sec.S3.PathPrefix)
+13 -5
View File
@@ -34,11 +34,19 @@ func newS3Client(endpoint, accessKey, secretKey, bucketName, region, pathPrefix
var cfg aws.Config
var err error
// Configure AWS SDK
cfg, err = config.LoadDefaultConfig(context.Background(),
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
)
// With no explicit AK/SK, keep the AWS default credential chain intact. This
// supports IAM roles for EC2/ECS/EKS (IRSA), web identity, shared config, and
// environment credentials without persisting long-lived keys in WeKnora.
loadOptions := []func(*config.LoadOptions) error{config.WithRegion(region)}
if accessKey != "" || secretKey != "" {
if accessKey == "" || secretKey == "" {
return nil, fmt.Errorf("S3 access key and secret key must be provided together")
}
loadOptions = append(loadOptions, config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""),
))
}
cfg, err = config.LoadDefaultConfig(context.Background(), loadOptions...)
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
@@ -1,10 +1,50 @@
package file
import (
"context"
"strings"
"testing"
)
func TestNewS3Client_Credentials(t *testing.T) {
t.Run("static credentials remain supported", func(t *testing.T) {
svc, err := newS3Client("", "static-ak", "static-sk", "bucket", "us-east-1", "", false)
if err != nil {
t.Fatalf("newS3Client() error = %v", err)
}
got, err := svc.client.Options().Credentials.Retrieve(context.Background())
if err != nil {
t.Fatalf("Retrieve() error = %v", err)
}
if got.AccessKeyID != "static-ak" || got.SecretAccessKey != "static-sk" {
t.Fatalf("unexpected credentials: access key %q", got.AccessKeyID)
}
})
t.Run("empty keys use the AWS default credential chain", func(t *testing.T) {
t.Setenv("AWS_ACCESS_KEY_ID", "role-ak")
t.Setenv("AWS_SECRET_ACCESS_KEY", "role-sk")
svc, err := newS3Client("", "", "", "bucket", "us-east-1", "", false)
if err != nil {
t.Fatalf("newS3Client() error = %v", err)
}
got, err := svc.client.Options().Credentials.Retrieve(context.Background())
if err != nil {
t.Fatalf("Retrieve() error = %v", err)
}
if got.AccessKeyID != "role-ak" || got.SecretAccessKey != "role-sk" {
t.Fatalf("default credential chain returned access key %q", got.AccessKeyID)
}
})
t.Run("partial static credentials are rejected", func(t *testing.T) {
_, err := newS3Client("", "only-ak", "", "bucket", "us-east-1", "", false)
if err == nil {
t.Fatal("newS3Client() expected an error")
}
})
}
func TestNewS3Client_PathStyleForCompatibleEndpoints(t *testing.T) {
tests := []struct {
name string
+6 -7
View File
@@ -961,11 +961,10 @@ func initRawFileService(_ *config.Config) (interfaces.FileService, error) {
os.Getenv("TOS_TEMP_REGION"), // 可选:临时桶 region,默认与主桶相同
)
case "s3":
if os.Getenv("S3_ENDPOINT") == "" ||
os.Getenv("S3_REGION") == "" ||
os.Getenv("S3_ACCESS_KEY") == "" ||
os.Getenv("S3_SECRET_KEY") == "" ||
os.Getenv("S3_BUCKET_NAME") == "" {
accessKey, secretKey := os.Getenv("S3_ACCESS_KEY"), os.Getenv("S3_SECRET_KEY")
if os.Getenv("S3_REGION") == "" ||
os.Getenv("S3_BUCKET_NAME") == "" ||
(accessKey == "") != (secretKey == "") {
return nil, fmt.Errorf("missing S3 configuration")
}
pathPrefix := os.Getenv("S3_PATH_PREFIX")
@@ -974,8 +973,8 @@ func initRawFileService(_ *config.Config) (interfaces.FileService, error) {
}
return file.NewS3FileService(
os.Getenv("S3_ENDPOINT"),
os.Getenv("S3_ACCESS_KEY"),
os.Getenv("S3_SECRET_KEY"),
accessKey,
secretKey,
os.Getenv("S3_BUCKET_NAME"),
os.Getenv("S3_REGION"),
pathPrefix,
+14 -8
View File
@@ -987,7 +987,7 @@ func (h *SystemHandler) isS3Configured(c *gin.Context) bool {
if v, exists := c.Get(types.TenantInfoContextKey.String()); exists {
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.StorageEngineConfig != nil && tenant.StorageEngineConfig.S3 != nil {
s3Conf := tenant.StorageEngineConfig.S3
return s3Conf.Endpoint != "" && s3Conf.Region != "" && s3Conf.AccessKey != "" && s3Conf.SecretKey != "" && s3Conf.BucketName != ""
return s3Conf.Region != "" && s3Conf.BucketName != "" && (s3Conf.AccessKey == "") == (s3Conf.SecretKey == "")
}
}
return false
@@ -1133,15 +1133,21 @@ func (h *SystemHandler) checkS3(c *gin.Context, ctx context.Context, cfg *types.
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "未提供 S3 配置"}})
return
}
if cfg.Endpoint == "" || cfg.Region == "" || cfg.AccessKey == "" || cfg.SecretKey == "" || cfg.BucketName == "" {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Endpoint、Region、Access Key、Secret Key、Bucket 名称不能为空"}})
if cfg.Region == "" || cfg.BucketName == "" {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Region、Bucket 名称不能为空"}})
return
}
if (cfg.AccessKey == "") != (cfg.SecretKey == "") {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "Access Key 与 Secret Key 必须同时填写或同时留空(使用 AWS 默认凭证链)"}})
return
}
if blocked, reason := isBlockedStorageEndpoint(cfg.Endpoint); blocked {
logger.Warnf(ctx, "Storage check: S3 endpoint blocked by SSRF protection, endpoint: %s", cfg.Endpoint)
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: reason}})
return
if cfg.Endpoint != "" {
if blocked, reason := isBlockedStorageEndpoint(cfg.Endpoint); blocked {
logger.Warnf(ctx, "Storage check: S3 endpoint blocked by SSRF protection, endpoint: %s", cfg.Endpoint)
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: reason}})
return
}
}
err := file.CheckS3Connectivity(ctx, cfg.Endpoint, cfg.AccessKey, cfg.SecretKey, cfg.BucketName, cfg.Region)
@@ -1149,7 +1155,7 @@ func (h *SystemHandler) checkS3(c *gin.Context, ctx context.Context, cfg *types.
logger.Errorf(ctx, "Storage check: S3 connectivity failed, bucket: %s, error: %v", cfg.BucketName, err)
errMsg := err.Error()
if strings.Contains(errMsg, "403") {
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "认证失败,请检查 Access Key / Secret Key 是否正确"}})
c.JSON(200, gin.H{"code": 0, "data": StorageCheckResponse{OK: false, Message: "认证失败,请检查静态密钥或 AWS IAM Role / 默认凭证链权限"}})
return
}
if strings.Contains(errMsg, "404") || strings.Contains(errMsg, "NotFound") {
+10 -2
View File
@@ -219,8 +219,16 @@ func MergeStorageEngineConfigForUpdate(incoming, existing *StorageEngineConfig)
if existing != nil && existing.S3 != nil {
prev = *existing.S3
}
s3.AccessKey = PreserveIfRedacted(s3.AccessKey, prev.AccessKey)
s3.SecretKey = PreserveIfRedacted(s3.SecretKey, prev.SecretKey)
// Empty S3 credentials intentionally switch authentication to the AWS
// default credential chain. Only the response placeholder means "keep
// the stored value"; treating empty as preserve makes it impossible to
// migrate an existing static AK/SK configuration to IAM roles.
if s3.AccessKey == RedactedSecretPlaceholder {
s3.AccessKey = prev.AccessKey
}
if s3.SecretKey == RedactedSecretPlaceholder {
s3.SecretKey = prev.SecretKey
}
out.S3 = &s3
}
if out.OSS != nil {
+46
View File
@@ -98,6 +98,52 @@ func TestMergeStorageEngineConfigForUpdate_PreservesRedactedSecrets(t *testing.T
assert.Equal(t, "bucket-new", merged.MinIO.BucketName)
}
func TestMergeStorageEngineConfigForUpdate_ClearsS3Credentials(t *testing.T) {
existing := &StorageEngineConfig{
DefaultProvider: "s3",
S3: &S3EngineConfig{
AccessKey: "stored-access-key",
SecretKey: "stored-secret-key",
Region: "us-east-1",
BucketName: "bucket",
},
}
t.Run("empty credentials enable the default credential chain", func(t *testing.T) {
incoming := &StorageEngineConfig{
DefaultProvider: "s3",
S3: &S3EngineConfig{
AccessKey: "",
SecretKey: "",
Region: "us-east-1",
BucketName: "bucket",
},
}
merged := MergeStorageEngineConfigForUpdate(incoming, existing)
require.NotNil(t, merged)
require.NotNil(t, merged.S3)
assert.Empty(t, merged.S3.AccessKey)
assert.Empty(t, merged.S3.SecretKey)
})
t.Run("redacted placeholders preserve stored credentials", func(t *testing.T) {
incoming := &StorageEngineConfig{
DefaultProvider: "s3",
S3: &S3EngineConfig{
AccessKey: RedactedSecretPlaceholder,
SecretKey: RedactedSecretPlaceholder,
Region: "us-east-1",
BucketName: "bucket",
},
}
merged := MergeStorageEngineConfigForUpdate(incoming, existing)
require.NotNil(t, merged)
require.NotNil(t, merged.S3)
assert.Equal(t, "stored-access-key", merged.S3.AccessKey)
assert.Equal(t, "stored-secret-key", merged.S3.SecretKey)
})
}
func TestParserEngineConfigForResponse_NilSafe(t *testing.T) {
assert.Nil(t, ParserEngineConfigForResponse(nil, true))
}
-3
View File
@@ -98,10 +98,7 @@ if [ "$STORAGE_TYPE" = "tos" ]; then
fi
if [ "$STORAGE_TYPE" = "s3" ]; then
check_var "S3_ENDPOINT"
check_var "S3_REGION"
check_var "S3_ACCESS_KEY"
check_var "S3_SECRET_KEY"
check_var "S3_BUCKET_NAME"
fi