mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-08-28 17:43:11 +08:00
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:
+6
-1
@@ -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 S3(STORAGE_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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user