Przeglądaj źródła

feat(volcengine): manage per-user asset groups on DoubaoVideo channels

Bring official Volcengine channels in line with the China Mobile asset
isolation model: the platform owns the upstream asset group lifecycle
per (user, channel). asset_group.* APIs are forbidden for clients;
CreateAsset is scoped to the user's managed group (client GroupId is
overwritten), ListAssets is scoped with Filter.GroupIds to the managed
group, and Get/Update/Delete verify ownership via GetAsset before
forwarding, returning not-found for foreign assets.

Generalize the shared managed-group plumbing (GetOrCreateUserAssetGroup,
ScopeManagedAssetRequest, RequireManagedAssetOwnership) and add the
DoubaoVideo-specific group creator (Ark CreateAssetGroup returns the id
in Result.Id; GroupType must be omitted). The DoubaoVideo asset adapter
now serializes the request body from req.Body so platform rewrites take
effect (RawBody was bypassing the scoping) and the V4 signature always
covers the actual payload.

Verified end-to-end: group ops rejected, forged GroupId overwritten by
the auto-created managed group, ListAssets returns only the managed
group, foreign assets hidden from get/delete.

Co-Authored-By: ZCode <noreply@anthropic.com>
master
fengsilin 6 dni temu
rodzic
commit
6f6c68787f
5 zmienionych plików z 104 dodań i 27 usunięć
  1. +15
    -7
      controller/doubao_asset.go
  2. +12
    -2
      service/asset_doubao.go
  3. +14
    -8
      service/chinamobile_user_asset_group.go
  4. +10
    -10
      service/chinamobile_user_asset_group_test.go
  5. +53
    -0
      service/doubao_user_asset_group.go

+ 15
- 7
controller/doubao_asset.go Wyświetl plik

@@ -107,21 +107,29 @@ func DoubaoAssetProxy(c *gin.Context) {
Body: body, Body: body,
RawBody: rawBody, RawBody: rawBody,
} }
if service.IsChinaMobileAssetChannel(channel) {
if service.IsChinaMobileAssetChannel(channel) || service.IsDoubaoVideoAssetChannel(channel) {
if service.IsAssetGroupOperation(action.Operation) { if service.IsAssetGroupOperation(action.Operation) {
assetProxyError(c, http.StatusForbidden, service.AssetErrorOperationNotSupported, "China Mobile asset group APIs are managed by the platform")
assetProxyError(c, http.StatusForbidden, service.AssetErrorOperationNotSupported, "asset group APIs are managed by the platform")
return return
} }
groupID, assetErr := service.GetOrCreateChinaMobileUserAssetGroup(c.Request.Context(), userID, channel, func(ctx context.Context, userID int, channel *model.Channel) (string, *service.AssetError) {
return service.CreateChinaMobileUserAssetGroup(ctx, userID, adapter, channel)
})
var createGroup service.ManagedAssetGroupCreator
if service.IsChinaMobileAssetChannel(channel) {
createGroup = func(ctx context.Context, userID int, ch *model.Channel) (string, *service.AssetError) {
return service.CreateChinaMobileUserAssetGroup(ctx, userID, adapter, ch)
}
} else {
createGroup = func(ctx context.Context, userID int, ch *model.Channel) (string, *service.AssetError) {
return service.CreateDoubaoVideoUserAssetGroup(ctx, userID, adapter, ch)
}
}
groupID, assetErr := service.GetOrCreateUserAssetGroup(c.Request.Context(), userID, channel, createGroup)
if assetErr != nil { if assetErr != nil {
assetProxyError(c, assetErr.HTTPStatus, assetErr.Type, assetErr.Message) assetProxyError(c, assetErr.HTTPStatus, assetErr.Type, assetErr.Message)
return return
} }
service.ScopeChinaMobileAssetRequest(&assetRequest, groupID)
service.ScopeManagedAssetRequest(&assetRequest, groupID)
if action.Operation == service.AssetOperationAssetGet || action.Operation == service.AssetOperationAssetUpdate || action.Operation == service.AssetOperationAssetDelete { if action.Operation == service.AssetOperationAssetGet || action.Operation == service.AssetOperationAssetUpdate || action.Operation == service.AssetOperationAssetDelete {
if assetErr := service.RequireChinaMobileAssetOwnership(c.Request.Context(), adapter, channel, assetRequest, groupID); assetErr != nil {
if assetErr := service.RequireManagedAssetOwnership(c.Request.Context(), adapter, channel, assetRequest, groupID); assetErr != nil {
assetProxyError(c, assetErr.HTTPStatus, assetErr.Type, assetErr.Message) assetProxyError(c, assetErr.HTTPStatus, assetErr.Type, assetErr.Message)
return return
} }


+ 12
- 2
service/asset_doubao.go Wyświetl plik

@@ -72,16 +72,26 @@ func (a *DoubaoVideoAssetAdapter) DoAssetRequest(ctx context.Context, channel *m
if err != nil { if err != nil {
return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError)
} }
// The body is always serialized from req.Body so platform-side rewrites
// (managed group scoping, ownership checks) take effect; RawBody is
// intentionally not used here. The V4 signature covers the sent payload.
body, err := common.Marshal(req.Body)
if err != nil {
return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError)
}
if len(body) == 0 {
body = []byte("{}")
}
fetchSetting := system_setting.GetFetchSetting() fetchSetting := system_setting.GetFetchSetting()
if err := common.ValidateURLWithFetchSetting(upstreamURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { if err := common.ValidateURLWithFetchSetting(upstreamURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
return nil, newAssetError(AssetErrorServer, fmt.Sprintf("request blocked: %v", err), http.StatusForbidden) return nil, newAssetError(AssetErrorServer, fmt.Sprintf("request blocked: %v", err), http.StatusForbidden)
} }


httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(req.RawBody))
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(body))
if err != nil { if err != nil {
return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError) return nil, newAssetError(AssetErrorServer, err.Error(), http.StatusInternalServerError)
} }
signDoubaoVideoAssetRequest(httpReq, credential.AccessKey, credential.SecretKey, req.RawBody, rawQuery)
signDoubaoVideoAssetRequest(httpReq, credential.AccessKey, credential.SecretKey, body, rawQuery)
if projectCode := strings.TrimSpace(credential.PoolID); projectCode != "" { if projectCode := strings.TrimSpace(credential.PoolID); projectCode != "" {
httpReq.Header.Set("X-Project-Code", projectCode) httpReq.Header.Set("X-Project-Code", projectCode)
} }


+ 14
- 8
service/chinamobile_user_asset_group.go Wyświetl plik

@@ -11,7 +11,9 @@ import (
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
) )


type chinaMobileAssetGroupCreator func(context.Context, int, *model.Channel) (string, *AssetError)
// ManagedAssetGroupCreator creates an upstream asset group for a user on a
// channel; implementations differ per upstream (China Mobile, DoubaoVideo).
type ManagedAssetGroupCreator func(context.Context, int, *model.Channel) (string, *AssetError)


func IsChinaMobileAssetChannel(channel *model.Channel) bool { func IsChinaMobileAssetChannel(channel *model.Channel) bool {
return channel != nil && channel.Type == constant.ChannelTypeChinaMobileSeedance return channel != nil && channel.Type == constant.ChannelTypeChinaMobileSeedance
@@ -21,9 +23,13 @@ func IsAssetGroupOperation(operation AssetOperation) bool {
return strings.HasPrefix(string(operation), "asset_group.") return strings.HasPrefix(string(operation), "asset_group.")
} }


func GetOrCreateChinaMobileUserAssetGroup(ctx context.Context, userId int, channel *model.Channel, createGroup chinaMobileAssetGroupCreator) (string, *AssetError) {
// GetOrCreateUserAssetGroup returns the platform-managed upstream asset group
// for (user, channel). Shared by managed channels (China Mobile, DoubaoVideo):
// the first request creates the upstream group via the provided creator and
// persists the mapping; concurrent first requests reconcile via re-read.
func GetOrCreateUserAssetGroup(ctx context.Context, userId int, channel *model.Channel, createGroup ManagedAssetGroupCreator) (string, *AssetError) {
if channel == nil { if channel == nil {
return "", newAssetError(AssetErrorServer, "China Mobile asset channel is required", http.StatusInternalServerError)
return "", newAssetError(AssetErrorServer, "managed asset channel is required", http.StatusInternalServerError)
} }
binding, err := model.GetUserAssetGroup(userId, channel.Id) binding, err := model.GetUserAssetGroup(userId, channel.Id)
if err != nil { if err != nil {
@@ -38,7 +44,7 @@ func GetOrCreateChinaMobileUserAssetGroup(ctx context.Context, userId int, chann
return "", assetErr return "", assetErr
} }
if strings.TrimSpace(groupId) == "" { if strings.TrimSpace(groupId) == "" {
return "", newAssetError(AssetErrorUpstream, "China Mobile asset group creation returned an empty group ID", http.StatusBadGateway)
return "", newAssetError(AssetErrorUpstream, "asset group creation returned an empty group ID", http.StatusBadGateway)
} }
if err := model.CreateUserAssetGroup(userId, channel.Id, groupId); err == nil { if err := model.CreateUserAssetGroup(userId, channel.Id, groupId); err == nil {
return groupId, nil return groupId, nil
@@ -52,7 +58,7 @@ func GetOrCreateChinaMobileUserAssetGroup(ctx context.Context, userId int, chann
if readErr != nil { if readErr != nil {
return "", newAssetError(AssetErrorServer, readErr.Error(), http.StatusInternalServerError) return "", newAssetError(AssetErrorServer, readErr.Error(), http.StatusInternalServerError)
} }
return "", newAssetError(AssetErrorServer, "failed to persist China Mobile user asset group", http.StatusInternalServerError)
return "", newAssetError(AssetErrorServer, "failed to persist user asset group", http.StatusInternalServerError)
} }


func CreateChinaMobileUserAssetGroup(ctx context.Context, userId int, adapter AssetAdapter, channel *model.Channel) (string, *AssetError) { func CreateChinaMobileUserAssetGroup(ctx context.Context, userId int, adapter AssetAdapter, channel *model.Channel) (string, *AssetError) {
@@ -82,7 +88,7 @@ func CreateChinaMobileUserAssetGroup(ctx context.Context, userId int, adapter As
return strings.TrimSpace(payload.Result.GroupId), nil return strings.TrimSpace(payload.Result.GroupId), nil
} }


func ScopeChinaMobileAssetRequest(req *AssetRequest, groupId string) {
func ScopeManagedAssetRequest(req *AssetRequest, groupId string) {
switch req.Action.Operation { switch req.Action.Operation {
case AssetOperationAssetCreate: case AssetOperationAssetCreate:
req.Body["GroupId"] = groupId req.Body["GroupId"] = groupId
@@ -96,7 +102,7 @@ func ScopeChinaMobileAssetRequest(req *AssetRequest, groupId string) {
} }
} }


func RequireChinaMobileAssetOwnership(ctx context.Context, adapter AssetAdapter, channel *model.Channel, request AssetRequest, groupId string) *AssetError {
func RequireManagedAssetOwnership(ctx context.Context, adapter AssetAdapter, channel *model.Channel, request AssetRequest, groupId string) *AssetError {
spec, ok := ParseAssetAction("GetAsset") spec, ok := ParseAssetAction("GetAsset")
if !ok { if !ok {
return newAssetError(AssetErrorServer, "GetAsset action is not registered", http.StatusInternalServerError) return newAssetError(AssetErrorServer, "GetAsset action is not registered", http.StatusInternalServerError)
@@ -119,7 +125,7 @@ func RequireChinaMobileAssetOwnership(ctx context.Context, adapter AssetAdapter,
} `json:"Result"` } `json:"Result"`
} }
if err := common.Unmarshal(resp.Body, &payload); err != nil { if err := common.Unmarshal(resp.Body, &payload); err != nil {
return newAssetError(AssetErrorUpstream, fmt.Sprintf("invalid China Mobile asset response: %v", err), http.StatusBadGateway)
return newAssetError(AssetErrorUpstream, fmt.Sprintf("invalid asset response: %v", err), http.StatusBadGateway)
} }
if strings.TrimSpace(payload.Result.GroupId) != groupId { if strings.TrimSpace(payload.Result.GroupId) != groupId {
return newAssetError(AssetErrorNotFound, "asset not found", http.StatusNotFound) return newAssetError(AssetErrorNotFound, "asset not found", http.StatusNotFound)


+ 10
- 10
service/chinamobile_user_asset_group_test.go Wyświetl plik

@@ -41,7 +41,7 @@ func chinaMobileAssetAction(t *testing.T, action string) AssetActionSpec {
return spec return spec
} }


func TestGetOrCreateChinaMobileUserAssetGroupCreatesThenReuses(t *testing.T) {
func TestGetOrCreateUserAssetGroupCreatesThenReuses(t *testing.T) {
setupChinaMobileUserAssetGroupDB(t) setupChinaMobileUserAssetGroupDB(t)
channel := &model.Channel{Id: 101, Type: constant.ChannelTypeChinaMobileSeedance} channel := &model.Channel{Id: 101, Type: constant.ChannelTypeChinaMobileSeedance}
createCalls := 0 createCalls := 0
@@ -50,17 +50,17 @@ func TestGetOrCreateChinaMobileUserAssetGroupCreatesThenReuses(t *testing.T) {
return "group-1", nil return "group-1", nil
} }


groupID, assetErr := GetOrCreateChinaMobileUserAssetGroup(context.Background(), 10, channel, creator)
groupID, assetErr := GetOrCreateUserAssetGroup(context.Background(), 10, channel, creator)
require.Nil(t, assetErr) require.Nil(t, assetErr)
assert.Equal(t, "group-1", groupID) assert.Equal(t, "group-1", groupID)


again, assetErr := GetOrCreateChinaMobileUserAssetGroup(context.Background(), 10, channel, creator)
again, assetErr := GetOrCreateUserAssetGroup(context.Background(), 10, channel, creator)
require.Nil(t, assetErr) require.Nil(t, assetErr)
assert.Equal(t, "group-1", again) assert.Equal(t, "group-1", again)
assert.Equal(t, 1, createCalls) assert.Equal(t, 1, createCalls)
} }


func TestGetOrCreateChinaMobileUserAssetGroupConcurrentRequestsReuseBinding(t *testing.T) {
func TestGetOrCreateUserAssetGroupConcurrentRequestsReuseBinding(t *testing.T) {
setupChinaMobileUserAssetGroupDB(t) setupChinaMobileUserAssetGroupDB(t)
channel := &model.Channel{Id: 102, Type: constant.ChannelTypeChinaMobileSeedance} channel := &model.Channel{Id: 102, Type: constant.ChannelTypeChinaMobileSeedance}
creator := func(context.Context, int, *model.Channel) (string, *AssetError) { creator := func(context.Context, int, *model.Channel) (string, *AssetError) {
@@ -75,7 +75,7 @@ func TestGetOrCreateChinaMobileUserAssetGroupConcurrentRequestsReuseBinding(t *t
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
groupID, assetErr := GetOrCreateChinaMobileUserAssetGroup(context.Background(), 10, channel, creator)
groupID, assetErr := GetOrCreateUserAssetGroup(context.Background(), 10, channel, creator)
results <- groupID results <- groupID
errs <- assetErr errs <- assetErr
}() }()
@@ -96,21 +96,21 @@ func TestGetOrCreateChinaMobileUserAssetGroupConcurrentRequestsReuseBinding(t *t
assert.Equal(t, "group-concurrent", binding.GroupId) assert.Equal(t, "group-concurrent", binding.GroupId)
} }


func TestScopeChinaMobileAssetRequestOverwritesClientGroup(t *testing.T) {
func TestScopeManagedAssetRequestOverwritesClientGroup(t *testing.T) {
create := AssetRequest{Action: chinaMobileAssetAction(t, "CreateAsset"), Body: map[string]any{"GroupId": "forged"}} create := AssetRequest{Action: chinaMobileAssetAction(t, "CreateAsset"), Body: map[string]any{"GroupId": "forged"}}
ScopeChinaMobileAssetRequest(&create, "owned")
ScopeManagedAssetRequest(&create, "owned")
assert.Equal(t, "owned", create.Body["GroupId"]) assert.Equal(t, "owned", create.Body["GroupId"])


list := AssetRequest{Action: chinaMobileAssetAction(t, "ListAssets"), Body: map[string]any{"Filter": map[string]any{"GroupIds": []any{"forged"}}}} list := AssetRequest{Action: chinaMobileAssetAction(t, "ListAssets"), Body: map[string]any{"Filter": map[string]any{"GroupIds": []any{"forged"}}}}
ScopeChinaMobileAssetRequest(&list, "owned")
ScopeManagedAssetRequest(&list, "owned")
assert.Equal(t, []string{"owned"}, list.Body["Filter"].(map[string]any)["GroupIds"]) assert.Equal(t, []string{"owned"}, list.Body["Filter"].(map[string]any)["GroupIds"])
} }


func TestRequireChinaMobileAssetOwnershipHidesMismatchedGroup(t *testing.T) {
func TestRequireManagedAssetOwnershipHidesMismatchedGroup(t *testing.T) {
adapter := &recordingAssetAdapter{getAssetGroupID: "other"} adapter := &recordingAssetAdapter{getAssetGroupID: "other"}
request := AssetRequest{Action: chinaMobileAssetAction(t, "DeleteAsset"), Body: map[string]any{"Id": "asset-1"}} request := AssetRequest{Action: chinaMobileAssetAction(t, "DeleteAsset"), Body: map[string]any{"Id": "asset-1"}}


assetErr := RequireChinaMobileAssetOwnership(context.Background(), adapter, &model.Channel{}, request, "owned")
assetErr := RequireManagedAssetOwnership(context.Background(), adapter, &model.Channel{}, request, "owned")


require.NotNil(t, assetErr) require.NotNil(t, assetErr)
assert.Equal(t, AssetErrorNotFound, assetErr.Type) assert.Equal(t, AssetErrorNotFound, assetErr.Type)


+ 53
- 0
service/doubao_user_asset_group.go Wyświetl plik

@@ -0,0 +1,53 @@
package service

import (
"context"
"fmt"
"net/http"
"strings"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
)

// IsDoubaoVideoAssetChannel reports whether the channel is an official
// Volcengine (DoubaoVideo) channel whose assets are managed by the platform.
func IsDoubaoVideoAssetChannel(channel *model.Channel) bool {
return channel != nil && channel.Type == constant.ChannelTypeDoubaoVideo
}

// CreateDoubaoVideoUserAssetGroup creates a per-user upstream asset group.
// The Ark API derives the group type internally; passing GroupType is
// rejected (InvalidParameter), so only the name is sent. The response
// carries the new group id in Result.Id (Result.GroupId is tolerated too).
func CreateDoubaoVideoUserAssetGroup(ctx context.Context, userId int, adapter AssetAdapter, channel *model.Channel) (string, *AssetError) {
spec, ok := ParseAssetAction("CreateAssetGroup")
if !ok {
return "", newAssetError(AssetErrorServer, "CreateAssetGroup action is not registered", http.StatusInternalServerError)
}
resp, assetErr := adapter.DoAssetRequest(ctx, channel, AssetRequest{
Action: spec,
Version: "2024-01-01",
Body: map[string]any{
"Name": fmt.Sprintf("new-api-user-%d-channel-%d", userId, channel.Id),
},
})
if assetErr != nil {
return "", assetErr
}
var payload struct {
Result struct {
Id string `json:"Id"`
GroupId string `json:"GroupId"`
} `json:"Result"`
}
if err := common.Unmarshal(resp.Body, &payload); err != nil {
return "", newAssetError(AssetErrorUpstream, fmt.Sprintf("invalid Volcengine asset group response: %v", err), http.StatusBadGateway)
}
groupId := strings.TrimSpace(payload.Result.Id)
if groupId == "" {
groupId = strings.TrimSpace(payload.Result.GroupId)
}
return groupId, nil
}

Ładowanie…
Anuluj
Zapisz