From bdae0925c687daa24726f68d3718e72839526ac1 Mon Sep 17 00:00:00 2001 From: binshiz Date: Tue, 23 Jun 2026 20:56:52 +0800 Subject: [PATCH] Add secondary development phase one foundation --- common/secret_box.go | 86 ++ controller/marketplace.go | 595 +++++++++ controller/marketplace_test.go | 126 ++ controller/rbac.go | 79 ++ controller/user.go | 4 + middleware/rbac.go | 31 + model/main.go | 30 + model/marketplace.go | 379 ++++++ model/rbac.go | 327 +++++ model/rbac_marketplace_test.go | 183 +++ model/task_cas_test.go | 28 + router/api-router.go | 47 +- .../src/features/secondary-development/api.ts | 203 +++ .../features/secondary-development/index.tsx | 1140 +++++++++++++++++ .../features/secondary-development/types.ts | 150 +++ .../components/common-logs-filter-bar.tsx | 53 +- .../components/common-logs-stats.tsx | 11 +- .../components/usage-logs-table.tsx | 35 +- .../src/features/usage-logs/constants.ts | 2 + web/default/src/features/usage-logs/index.tsx | 24 +- .../src/features/usage-logs/lib/utils.ts | 24 +- .../features/usage-logs/section-registry.tsx | 5 + web/default/src/features/usage-logs/types.ts | 3 +- web/default/src/hooks/use-sidebar-config.ts | 10 +- web/default/src/hooks/use-sidebar-data.ts | 30 + web/default/src/hooks/use-sidebar-view.ts | 45 +- web/default/src/i18n/locales/en.json | 44 +- web/default/src/i18n/locales/fr.json | 56 +- web/default/src/i18n/locales/ja.json | 46 +- web/default/src/i18n/locales/ru.json | 48 +- web/default/src/i18n/locales/vi.json | 56 +- web/default/src/i18n/locales/zh.json | 46 +- web/default/src/lib/rbac.ts | 60 + web/default/src/routeTree.gen.ts | 88 ++ .../routes/_authenticated/finance/index.tsx | 38 + .../_authenticated/marketplace/index.tsx | 33 + .../_authenticated/provider-console/index.tsx | 38 + .../src/routes/_authenticated/rbac/index.tsx | 33 + .../_authenticated/usage-logs/$section.tsx | 22 +- web/default/src/stores/auth-store.ts | 2 + 40 files changed, 4172 insertions(+), 88 deletions(-) create mode 100644 common/secret_box.go create mode 100644 controller/marketplace.go create mode 100644 controller/marketplace_test.go create mode 100644 controller/rbac.go create mode 100644 middleware/rbac.go create mode 100644 model/marketplace.go create mode 100644 model/rbac.go create mode 100644 model/rbac_marketplace_test.go create mode 100644 web/default/src/features/secondary-development/api.ts create mode 100644 web/default/src/features/secondary-development/index.tsx create mode 100644 web/default/src/features/secondary-development/types.ts create mode 100644 web/default/src/lib/rbac.ts create mode 100644 web/default/src/routes/_authenticated/finance/index.tsx create mode 100644 web/default/src/routes/_authenticated/marketplace/index.tsx create mode 100644 web/default/src/routes/_authenticated/provider-console/index.tsx create mode 100644 web/default/src/routes/_authenticated/rbac/index.tsx diff --git a/common/secret_box.go b/common/secret_box.go new file mode 100644 index 000000000000..bcfc7ef59f76 --- /dev/null +++ b/common/secret_box.go @@ -0,0 +1,86 @@ +package common + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "io" + "os" +) + +const modelKeyEncryptionSecretEnv = "MODEL_KEY_ENCRYPTION_SECRET" + +var ErrModelKeyEncryptionSecretMissing = errors.New("MODEL_KEY_ENCRYPTION_SECRET is required") + +func modelKeyEncryptionKey() ([]byte, error) { + secret := os.Getenv(modelKeyEncryptionSecretEnv) + if secret == "" { + return nil, ErrModelKeyEncryptionSecretMissing + } + sum := sha256.Sum256([]byte(secret)) + return sum[:], nil +} + +func EncryptModelKey(plaintext string) (string, error) { + key, err := modelKeyEncryptionKey() + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +func DecryptModelKey(ciphertext string) (string, error) { + key, err := modelKeyEncryptionKey() + if err != nil { + return "", err + } + raw, err := base64.StdEncoding.DecodeString(ciphertext) + if err != nil { + return "", err + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(raw) < gcm.NonceSize() { + return "", errors.New("invalid encrypted model key") + } + nonce := raw[:gcm.NonceSize()] + body := raw[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, body, nil) + if err != nil { + return "", err + } + return string(plaintext), nil +} + +func MaskSecret(secret string) string { + if secret == "" { + return "" + } + runes := []rune(secret) + if len(runes) <= 8 { + return "****" + } + return string(runes[:4]) + "****" + string(runes[len(runes)-4:]) +} diff --git a/controller/marketplace.go b/controller/marketplace.go new file mode 100644 index 000000000000..585455639418 --- /dev/null +++ b/controller/marketplace.go @@ -0,0 +1,595 @@ +package controller + +import ( + "errors" + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type modelKeyRequest struct { + Name string `json:"name"` + Key string `json:"key"` + Status string `json:"status"` +} + +type reviewRecordRequest struct { + Action string `json:"action"` + Comment string `json:"comment"` +} + +func ListProviders(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + if canManageAllProviders(c) { + profiles, total, err := model.ListProviderProfiles(c.Query("keyword"), pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(profiles) + common.ApiSuccess(c, pageInfo) + return + } + profile, err := currentProviderProfile(c) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + pageInfo.SetTotal(0) + pageInfo.SetItems([]model.ProviderProfile{}) + common.ApiSuccess(c, pageInfo) + return + } + common.ApiError(c, err) + return + } + pageInfo.SetTotal(1) + pageInfo.SetItems([]model.ProviderProfile{*profile}) + common.ApiSuccess(c, pageInfo) +} + +func GetProvider(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + if !canManageAllProviders(c) { + profile, err := currentProviderProfile(c) + if err != nil { + common.ApiError(c, err) + return + } + if profile.Id != id { + common.ApiErrorMsg(c, "permission denied") + return + } + } + var profile model.ProviderProfile + if err := model.DB.First(&profile, id).Error; err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, profile) +} + +func UpsertProvider(c *gin.Context) { + var profile model.ProviderProfile + if err := c.ShouldBindJSON(&profile); err != nil { + common.ApiError(c, err) + return + } + if canManageAllProviders(c) && c.Param("id") != "" { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + profile.Id = id + } + if profile.Name == "" { + common.ApiErrorMsg(c, "provider name is required") + return + } + if !canManageAllProviders(c) { + existing, err := currentProviderProfile(c) + switch { + case err == nil: + profile.Id = existing.Id + profile.UserId = existing.UserId + case errors.Is(err, gorm.ErrRecordNotFound): + profile.UserId = c.GetInt("id") + default: + common.ApiError(c, err) + return + } + } + if profile.UserId == 0 { + common.ApiErrorMsg(c, "provider user_id is required") + return + } + if err := model.DB.Save(&profile).Error; err != nil { + common.ApiError(c, err) + return + } + if err := model.EnsureProviderFinancialRows(profile.Id); err != nil { + common.ApiError(c, err) + return + } + auditMarketplace(c, "provider.upsert", map[string]interface{}{"provider_id": profile.Id}) + common.ApiSuccess(c, profile) +} + +func GetProviderWallet(c *gin.Context) { + providerId, ok := resolveProviderId(c) + if !ok { + return + } + var wallet model.ProviderWallet + if err := model.DB.Where("provider_id = ?", providerId).First(&wallet).Error; err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, wallet) +} + +func UpdateProviderWallet(c *gin.Context) { + providerId, ok := resolveProviderId(c) + if !ok { + return + } + var wallet model.ProviderWallet + if err := c.ShouldBindJSON(&wallet); err != nil { + common.ApiError(c, err) + return + } + wallet.ProviderId = providerId + if err := model.DB.Where("provider_id = ?", providerId).Assign(wallet).FirstOrCreate(&wallet).Error; err != nil { + common.ApiError(c, err) + return + } + auditMarketplace(c, "provider.wallet.update", map[string]interface{}{"provider_id": providerId}) + common.ApiSuccess(c, wallet) +} + +func GetProviderSettlement(c *gin.Context) { + providerId, ok := resolveProviderId(c) + if !ok { + return + } + var settlement model.ProviderSettlementConfig + if err := model.DB.Where("provider_id = ?", providerId).First(&settlement).Error; err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, settlement) +} + +func UpdateProviderSettlement(c *gin.Context) { + providerId, ok := resolveProviderId(c) + if !ok { + return + } + var settlement model.ProviderSettlementConfig + if err := c.ShouldBindJSON(&settlement); err != nil { + common.ApiError(c, err) + return + } + settlement.ProviderId = providerId + if err := model.DB.Where("provider_id = ?", providerId).Assign(settlement).FirstOrCreate(&settlement).Error; err != nil { + common.ApiError(c, err) + return + } + auditMarketplace(c, "provider.settlement.update", map[string]interface{}{"provider_id": providerId}) + common.ApiSuccess(c, settlement) +} + +func ListMarketplaceModels(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + providerId, _ := strconv.Atoi(c.Query("provider_id")) + listedOnly := c.Query("listed_only") == "true" + if !canManageAllMarketplace(c) { + if canManageOwnMarketplace(c) && !listedOnly { + profile, err := currentProviderProfile(c) + if err != nil { + common.ApiError(c, err) + return + } + providerId = profile.Id + } else { + listedOnly = true + providerId = 0 + } + } + items, total, err := model.ListMarketplaceModels(c.Query("keyword"), providerId, listedOnly, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(items) + common.ApiSuccess(c, pageInfo) +} + +func GetMarketplaceModel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + detail, err := model.GetMarketplaceModelDetail(id) + if err != nil { + common.ApiError(c, err) + return + } + if canManageAllMarketplace(c) { + common.ApiSuccess(c, detail) + return + } + if canManageOwnMarketplace(c) { + profile, err := currentProviderProfile(c) + if err != nil { + common.ApiError(c, err) + return + } + if profile.Id == detail.ProviderId { + common.ApiSuccess(c, detail) + return + } + } + if detail.Status != model.MarketplaceModelStatusListed { + common.ApiErrorMsg(c, "permission denied") + return + } + detail.ApiConfigs = []model.ModelApiConfig{} + detail.Keys = []model.ModelKey{} + detail.Reviews = []model.ModelReviewRecord{} + detail.Wallet = nil + detail.Settlement = nil + common.ApiSuccess(c, detail) +} + +func CreateMarketplaceModel(c *gin.Context) { + var item model.MarketplaceModel + if err := c.ShouldBindJSON(&item); err != nil { + common.ApiError(c, err) + return + } + if item.Name == "" { + common.ApiErrorMsg(c, "model name is required") + return + } + if !canManageAllMarketplace(c) { + profile, err := currentProviderProfile(c) + if err != nil { + common.ApiError(c, err) + return + } + item.ProviderId = profile.Id + } + if item.ProviderId == 0 { + common.ApiErrorMsg(c, "provider_id is required") + return + } + if err := model.DB.Create(&item).Error; err != nil { + common.ApiError(c, err) + return + } + auditMarketplace(c, "marketplace.model.create", map[string]interface{}{"model_id": item.Id}) + common.ApiSuccess(c, item) +} + +func UpdateMarketplaceModel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + var item model.MarketplaceModel + if err := model.DB.First(&item, id).Error; err != nil { + common.ApiError(c, err) + return + } + allAccess := canManageAllMarketplace(c) + if !requireModelOwnershipOrPermission(c, id, allAccess) { + return + } + var req model.MarketplaceModel + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + if !allAccess { + req.ProviderId = item.ProviderId + } + if err := model.DB.Model(&item).Select("provider_id", "name", "description", "model_type", "tags", "context_length", "billing_type", "status", "recommended", "sort_order").Updates(req).Error; err != nil { + common.ApiError(c, err) + return + } + auditMarketplace(c, "marketplace.model.update", map[string]interface{}{"model_id": id}) + common.ApiSuccess(c, item) +} + +func DeleteMarketplaceModel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + if !requireModelOwnershipOrPermission(c, id, canManageAllMarketplace(c)) { + return + } + if err := model.DB.Delete(&model.MarketplaceModel{}, id).Error; err != nil { + common.ApiError(c, err) + return + } + auditMarketplace(c, "marketplace.model.delete", map[string]interface{}{"model_id": id}) + common.ApiSuccess(c, nil) +} + +func UpsertModelApiConfig(c *gin.Context) { + modelId, ok := resolveModelId(c) + if !ok { + return + } + var config model.ModelApiConfig + if err := c.ShouldBindJSON(&config); err != nil { + common.ApiError(c, err) + return + } + config.ModelId = modelId + if config.Id > 0 { + if err := model.DB.Model(&model.ModelApiConfig{}).Where("id = ? AND model_id = ?", config.Id, modelId).Updates(config).Error; err != nil { + common.ApiError(c, err) + return + } + } else if err := model.DB.Create(&config).Error; err != nil { + common.ApiError(c, err) + return + } + auditMarketplace(c, "marketplace.api_config.upsert", map[string]interface{}{"model_id": modelId}) + common.ApiSuccess(c, config) +} + +func CreateModelKey(c *gin.Context) { + modelId, ok := resolveModelId(c) + if !ok { + return + } + if !requireModelOwnershipOrPermission(c, modelId, canManageAllKeys(c)) { + return + } + var req modelKeyRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + key := model.ModelKey{ModelId: modelId, Name: req.Name, Status: req.Status} + if key.Name == "" { + key.Name = "default" + } + if err := model.SetModelKeyPlaintext(&key, req.Key); err != nil { + common.ApiError(c, err) + return + } + if err := model.DB.Create(&key).Error; err != nil { + common.ApiError(c, err) + return + } + auditMarketplace(c, "marketplace.key.create", map[string]interface{}{"model_id": modelId, "key_id": key.Id}) + common.ApiSuccess(c, key) +} + +func UpdateModelKey(c *gin.Context) { + modelId, ok := resolveModelId(c) + if !ok { + return + } + if !requireModelOwnershipOrPermission(c, modelId, canManageAllKeys(c)) { + return + } + keyId, err := strconv.Atoi(c.Param("key_id")) + if err != nil { + common.ApiError(c, err) + return + } + var existing model.ModelKey + if err := model.DB.Where("id = ? AND model_id = ?", keyId, modelId).First(&existing).Error; err != nil { + common.ApiError(c, err) + return + } + var req modelKeyRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + if req.Name != "" { + existing.Name = req.Name + } + if req.Status != "" { + existing.Status = req.Status + } + if req.Key != "" { + if err := model.SetModelKeyPlaintext(&existing, req.Key); err != nil { + common.ApiError(c, err) + return + } + } + if err := model.DB.Save(&existing).Error; err != nil { + common.ApiError(c, err) + return + } + auditMarketplace(c, "marketplace.key.update", map[string]interface{}{"model_id": modelId, "key_id": keyId}) + common.ApiSuccess(c, existing) +} + +func DeleteModelKey(c *gin.Context) { + modelId, ok := resolveModelId(c) + if !ok { + return + } + if !requireModelOwnershipOrPermission(c, modelId, canManageAllKeys(c)) { + return + } + keyId, err := strconv.Atoi(c.Param("key_id")) + if err != nil { + common.ApiError(c, err) + return + } + if err := model.DB.Where("id = ? AND model_id = ?", keyId, modelId).Delete(&model.ModelKey{}).Error; err != nil { + common.ApiError(c, err) + return + } + auditMarketplace(c, "marketplace.key.delete", map[string]interface{}{"model_id": modelId, "key_id": keyId}) + common.ApiSuccess(c, nil) +} + +func UpsertModelPricing(c *gin.Context) { + modelId, ok := resolveModelId(c) + if !ok { + return + } + var pricing model.ModelPricing + if err := c.ShouldBindJSON(&pricing); err != nil { + common.ApiError(c, err) + return + } + pricing.ModelId = modelId + if pricing.Id > 0 { + if err := model.DB.Model(&model.ModelPricing{}).Where("id = ? AND model_id = ?", pricing.Id, modelId).Updates(pricing).Error; err != nil { + common.ApiError(c, err) + return + } + } else if err := model.DB.Create(&pricing).Error; err != nil { + common.ApiError(c, err) + return + } + auditMarketplace(c, "marketplace.pricing.upsert", map[string]interface{}{"model_id": modelId, "pricing_id": pricing.Id}) + common.ApiSuccess(c, pricing) +} + +func CreateModelReviewRecord(c *gin.Context) { + modelId, ok := resolveModelId(c) + if !ok { + return + } + var req reviewRecordRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + record := model.ModelReviewRecord{ + ModelId: modelId, + ReviewerId: c.GetInt("id"), + Action: req.Action, + Comment: req.Comment, + } + if record.Action == "" { + common.ApiErrorMsg(c, "review action is required") + return + } + if err := model.DB.Create(&record).Error; err != nil { + common.ApiError(c, err) + return + } + auditMarketplace(c, "marketplace.review.create", map[string]interface{}{"model_id": modelId, "action": record.Action}) + common.ApiSuccess(c, record) +} + +func currentProviderProfile(c *gin.Context) (*model.ProviderProfile, error) { + return model.GetProviderProfileByUserId(c.GetInt("id")) +} + +func canManageAllProviders(c *gin.Context) bool { + ok, _ := model.UserHasPermission(c.GetInt("id"), c.GetInt("role"), model.PermissionProviderManage) + return ok +} + +func canManageAllMarketplace(c *gin.Context) bool { + ok, _ := model.UserHasPermission(c.GetInt("id"), c.GetInt("role"), model.PermissionMarketplaceManage) + return ok +} + +func canManageOwnMarketplace(c *gin.Context) bool { + ok, _ := model.UserHasPermission(c.GetInt("id"), c.GetInt("role"), model.PermissionMarketplaceSelfManage) + return ok +} + +func canManageAllKeys(c *gin.Context) bool { + ok, _ := model.UserHasPermission(c.GetInt("id"), c.GetInt("role"), model.PermissionMarketplaceKeyManage) + return ok +} + +func resolveProviderId(c *gin.Context) (int, bool) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return 0, false + } + if !canManageAllProviders(c) { + profile, err := currentProviderProfile(c) + if err != nil { + common.ApiError(c, err) + return 0, false + } + if profile.Id != id { + common.ApiErrorMsg(c, "permission denied") + return 0, false + } + } + return id, true +} + +func resolveModelId(c *gin.Context) (int, bool) { + modelId, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return 0, false + } + if !requireModelOwnershipOrPermission(c, modelId, canManageAllMarketplace(c)) { + return 0, false + } + return modelId, true +} + +func requireModelOwnershipOrPermission(c *gin.Context, modelId int, allPermission bool) bool { + if allPermission { + return true + } + profile, err := currentProviderProfile(c) + if err != nil { + common.ApiError(c, err) + return false + } + owns, err := model.ProviderOwnsModel(profile.Id, modelId) + if err != nil { + common.ApiError(c, err) + return false + } + if !owns { + common.ApiErrorMsg(c, "permission denied") + return false + } + return true +} + +func auditMarketplace(c *gin.Context, action string, params map[string]interface{}) { + model.RecordOperationAuditLog( + c.GetInt("id"), + action, + c.ClientIP(), + action, + params, + map[string]interface{}{ + "admin_id": c.GetInt("id"), + "admin_username": c.GetString("username"), + "admin_role": c.GetInt("role"), + }, + map[string]interface{}{"route": c.FullPath(), "method": c.Request.Method, "success": true}, + ) + common.SetContextKey(c, constant.ContextKeyAuditLogged, true) +} diff --git a/controller/marketplace_test.go b/controller/marketplace_test.go new file mode 100644 index 000000000000..1ab2b3c9dd2a --- /dev/null +++ b/controller/marketplace_test.go @@ -0,0 +1,126 @@ +package controller + +import ( + "net/http" + "strconv" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type marketplaceAPIResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data struct { + Items []model.MarketplaceModel `json:"items"` + Total int `json:"total"` + } `json:"data"` +} + +type marketplaceDetailAPIResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data model.MarketplaceModelDetail `json:"data"` +} + +func TestMarketplacePublicUserOnlySeesListedModels(t *testing.T) { + db := setupMarketplaceControllerTestDB(t) + provider := seedMarketplaceProvider(t, db, 101) + listed := model.MarketplaceModel{ProviderId: provider.Id, Name: "listed-model", Status: model.MarketplaceModelStatusListed} + draft := model.MarketplaceModel{ProviderId: provider.Id, Name: "draft-model", Status: model.MarketplaceModelStatusDraft} + require.NoError(t, db.Create(&listed).Error) + require.NoError(t, db.Create(&draft).Error) + + ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/marketplace-models/?page_size=20", nil, 201) + ctx.Set("role", common.RoleCommonUser) + + ListMarketplaceModels(ctx) + + var response marketplaceAPIResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.True(t, response.Success) + require.Len(t, response.Data.Items, 1) + assert.Equal(t, listed.Id, response.Data.Items[0].Id) + assert.Equal(t, 1, response.Data.Total) +} + +func TestMarketplacePublicDetailHidesManagementData(t *testing.T) { + db := setupMarketplaceControllerTestDB(t) + t.Setenv("MODEL_KEY_ENCRYPTION_SECRET", "controller-secret") + provider := seedMarketplaceProvider(t, db, 102) + item := model.MarketplaceModel{ProviderId: provider.Id, Name: "public-model", Status: model.MarketplaceModelStatusListed} + require.NoError(t, db.Create(&item).Error) + config := model.ModelApiConfig{ModelId: item.Id, BaseUrl: "https://upstream.example", Protocol: "openai", Status: "active"} + require.NoError(t, db.Create(&config).Error) + key := model.ModelKey{ModelId: item.Id, Name: "primary", Status: "active"} + require.NoError(t, model.SetModelKeyPlaintext(&key, "sk-public-secret")) + require.NoError(t, db.Create(&key).Error) + + ctx, recorder := newAuthenticatedContext(t, http.MethodGet, "/api/marketplace-models/"+strconv.Itoa(item.Id), nil, 202) + ctx.Set("role", common.RoleCommonUser) + ctx.Params = append(ctx.Params, ginParam("id", strconv.Itoa(item.Id))) + + GetMarketplaceModel(ctx) + + var response marketplaceDetailAPIResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.True(t, response.Success) + assert.Equal(t, item.Id, response.Data.Id) + assert.Empty(t, response.Data.ApiConfigs) + assert.Empty(t, response.Data.Keys) + assert.Empty(t, response.Data.Reviews) + assert.Nil(t, response.Data.Wallet) + assert.Nil(t, response.Data.Settlement) + assert.NotContains(t, recorder.Body.String(), "sk-public-secret") + assert.NotContains(t, recorder.Body.String(), "upstream.example") +} + +func setupMarketplaceControllerTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db := openTokenControllerTestDB(t) + require.NoError(t, db.AutoMigrate( + &model.User{}, + &model.Log{}, + &model.Role{}, + &model.Permission{}, + &model.UserRole{}, + &model.RolePermission{}, + &model.ProviderProfile{}, + &model.ProviderWallet{}, + &model.ProviderSettlementConfig{}, + &model.MarketplaceModel{}, + &model.ModelApiConfig{}, + &model.ModelKey{}, + &model.ModelPricing{}, + &model.ModelReviewRecord{}, + )) + require.NoError(t, model.EnsureBuiltinRBAC()) + return db +} + +func seedMarketplaceProvider(t *testing.T, db *gorm.DB, userId int) model.ProviderProfile { + t.Helper() + + require.NoError(t, db.Create(&model.User{ + Id: userId, + Username: "provider-" + strconv.Itoa(userId), + Password: "password", + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + }).Error) + provider := model.ProviderProfile{UserId: userId, Name: "provider-" + strconv.Itoa(userId)} + require.NoError(t, db.Create(&provider).Error) + return provider +} + +func ginParam(key string, value string) gin.Param { + return gin.Param{Key: key, Value: value} +} diff --git a/controller/rbac.go b/controller/rbac.go new file mode 100644 index 000000000000..ecbe08d00d9f --- /dev/null +++ b/controller/rbac.go @@ -0,0 +1,79 @@ +package controller + +import ( + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +type replaceUserRolesRequest struct { + RoleCodes []string `json:"role_codes"` +} + +func ListRBACRoles(c *gin.Context) { + roles, err := model.ListRolesWithPermissions() + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, roles) +} + +func ListRBACPermissions(c *gin.Context) { + permissions, err := model.ListPermissions() + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, permissions) +} + +func ListRBACUserRoles(c *gin.Context) { + userId, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + roles, err := model.ListUserRoles(userId) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, roles) +} + +func ReplaceRBACUserRoles(c *gin.Context) { + userId, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + var req replaceUserRolesRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + if err := model.ReplaceUserRoles(userId, req.RoleCodes); err != nil { + common.ApiError(c, err) + return + } + model.RecordOperationAuditLog( + userId, + "RBAC roles updated", + c.ClientIP(), + "rbac.user_roles.update", + map[string]interface{}{"target_user_id": userId, "role_codes": req.RoleCodes}, + map[string]interface{}{ + "admin_id": c.GetInt("id"), + "admin_username": c.GetString("username"), + "admin_role": c.GetInt("role"), + }, + map[string]interface{}{"route": c.FullPath(), "method": c.Request.Method, "success": true}, + ) + common.SetContextKey(c, constant.ContextKeyAuditLogged, true) + common.ApiSuccess(c, gin.H{"user_id": userId, "role_codes": req.RoleCodes}) +} diff --git a/controller/user.go b/controller/user.go index 33c7b1dff76c..e05690169331 100644 --- a/controller/user.go +++ b/controller/user.go @@ -443,6 +443,10 @@ func GetSelf(c *gin.Context) { // 计算用户权限信息 permissions := calculateUserPermissions(userRole) + roleCodes, _ := model.GetUserRoleCodes(id, userRole) + permissionCodes, _ := model.GetUserPermissionCodes(id, userRole) + permissions["role_codes"] = roleCodes + permissions["permission_codes"] = permissionCodes // 获取用户设置并提取sidebar_modules userSetting := user.GetSetting() diff --git a/middleware/rbac.go b/middleware/rbac.go new file mode 100644 index 000000000000..ea06ec82cc27 --- /dev/null +++ b/middleware/rbac.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/i18n" + "github.com/QuantumNous/new-api/model" + + "github.com/gin-gonic/gin" +) + +func PermissionAuth(permissions ...string) gin.HandlerFunc { + return func(c *gin.Context) { + ok, err := model.UserHasAnyPermission(c.GetInt("id"), c.GetInt("role"), permissions...) + if err != nil { + common.ApiError(c, err) + c.Abort() + return + } + if !ok { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege), + }) + c.Abort() + return + } + c.Next() + } +} diff --git a/model/main.go b/model/main.go index f886d00b8507..9b08575537c0 100644 --- a/model/main.go +++ b/model/main.go @@ -295,10 +295,25 @@ func migrateDB() error { &UserOAuthBinding{}, &PerfMetric{}, &SystemTask{}, + &Role{}, + &Permission{}, + &UserRole{}, + &RolePermission{}, + &ProviderProfile{}, + &ProviderWallet{}, + &ProviderSettlementConfig{}, + &MarketplaceModel{}, + &ModelApiConfig{}, + &ModelKey{}, + &ModelPricing{}, + &ModelReviewRecord{}, ) if err != nil { return err } + if err := EnsureBuiltinRBAC(); err != nil { + return err + } if common.UsingMainDatabase(common.DatabaseTypeSQLite) { if err := ensureSubscriptionPlanTableSQLite(); err != nil { return err @@ -345,6 +360,18 @@ func migrateDBFast() error { {&UserOAuthBinding{}, "UserOAuthBinding"}, {&PerfMetric{}, "PerfMetric"}, {&SystemTask{}, "SystemTask"}, + {&Role{}, "Role"}, + {&Permission{}, "Permission"}, + {&UserRole{}, "UserRole"}, + {&RolePermission{}, "RolePermission"}, + {&ProviderProfile{}, "ProviderProfile"}, + {&ProviderWallet{}, "ProviderWallet"}, + {&ProviderSettlementConfig{}, "ProviderSettlementConfig"}, + {&MarketplaceModel{}, "MarketplaceModel"}, + {&ModelApiConfig{}, "ModelApiConfig"}, + {&ModelKey{}, "ModelKey"}, + {&ModelPricing{}, "ModelPricing"}, + {&ModelReviewRecord{}, "ModelReviewRecord"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) @@ -378,6 +405,9 @@ func migrateDBFast() error { return err } } + if err := EnsureBuiltinRBAC(); err != nil { + return err + } common.SysLog("database migrated") return nil } diff --git a/model/marketplace.go b/model/marketplace.go new file mode 100644 index 000000000000..6b6890da2630 --- /dev/null +++ b/model/marketplace.go @@ -0,0 +1,379 @@ +package model + +import ( + "errors" + "strings" + + "github.com/QuantumNous/new-api/common" + + "gorm.io/gorm" +) + +const ( + MarketplaceModelStatusDraft = "draft" + MarketplaceModelStatusPendingReview = "pending_review" + MarketplaceModelStatusApproved = "approved" + MarketplaceModelStatusRejected = "rejected" + MarketplaceModelStatusListed = "listed" + MarketplaceModelStatusUnlisted = "unlisted" + MarketplaceModelStatusDisabled = "disabled" +) + +type ProviderProfile struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"not null;uniqueIndex"` + Name string `json:"name" gorm:"size:128;not null;index"` + Description string `json:"description,omitempty" gorm:"type:text"` + Contact string `json:"contact,omitempty" gorm:"size:255"` + Status string `json:"status" gorm:"size:32;default:'active';index"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +type ProviderWallet struct { + Id int `json:"id"` + ProviderId int `json:"provider_id" gorm:"not null;uniqueIndex"` + Currency string `json:"currency" gorm:"size:16;default:'USDT'"` + Balance float64 `json:"balance" gorm:"default:0"` + AvailableBalance float64 `json:"available_balance" gorm:"default:0"` + FrozenBalance float64 `json:"frozen_balance" gorm:"default:0"` + WalletAddress string `json:"wallet_address,omitempty" gorm:"size:255"` + WalletAddressMask string `json:"wallet_address_mask,omitempty" gorm:"size:255"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +type ProviderSettlementConfig struct { + Id int `json:"id"` + ProviderId int `json:"provider_id" gorm:"not null;uniqueIndex"` + Currency string `json:"currency" gorm:"size:16;default:'USDT'"` + UsdtRate float64 `json:"usdt_rate" gorm:"default:1"` + CommissionRatio float64 `json:"commission_ratio" gorm:"default:0"` + MinWithdrawal float64 `json:"min_withdrawal" gorm:"default:0"` + WithdrawalFee float64 `json:"withdrawal_fee" gorm:"default:0"` + DailyWithdrawalMax float64 `json:"daily_withdrawal_max" gorm:"default:0"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +type MarketplaceModel struct { + Id int `json:"id"` + ProviderId int `json:"provider_id" gorm:"not null;index"` + Name string `json:"name" gorm:"size:128;not null;index"` + Description string `json:"description,omitempty" gorm:"type:text"` + ModelType string `json:"model_type,omitempty" gorm:"size:64;index"` + Tags string `json:"tags,omitempty" gorm:"type:text"` + ContextLength int `json:"context_length" gorm:"default:0"` + BillingType string `json:"billing_type,omitempty" gorm:"size:32"` + Status string `json:"status" gorm:"size:32;default:'draft';index"` + Recommended bool `json:"recommended" gorm:"index"` + SortOrder int `json:"sort_order" gorm:"default:0;index"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + Provider *ProviderProfile `json:"provider,omitempty" gorm:"-"` +} + +type ModelApiConfig struct { + Id int `json:"id"` + ModelId int `json:"model_id" gorm:"not null;index"` + BaseUrl string `json:"base_url" gorm:"type:text"` + Protocol string `json:"protocol" gorm:"size:64;default:'openai'"` + AuthType string `json:"auth_type" gorm:"size:64;default:'bearer'"` + ModelMapping string `json:"model_mapping,omitempty" gorm:"type:text"` + RequestFormat string `json:"request_format,omitempty" gorm:"type:text"` + ResponseFormat string `json:"response_format,omitempty" gorm:"type:text"` + Status string `json:"status" gorm:"size:32;default:'active';index"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +type ModelKey struct { + Id int `json:"id"` + ModelId int `json:"model_id" gorm:"not null;index"` + Name string `json:"name" gorm:"size:128;not null"` + KeyCipher string `json:"-" gorm:"type:text;column:key_cipher;not null"` + KeyMask string `json:"key_mask" gorm:"size:64"` + Status string `json:"status" gorm:"size:32;default:'active';index"` + LastCheckedAt int64 `json:"last_checked_at" gorm:"bigint;default:0"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +type ModelPricing struct { + Id int `json:"id"` + ModelId int `json:"model_id" gorm:"not null;index"` + InputPrice float64 `json:"input_price" gorm:"default:0"` + OutputPrice float64 `json:"output_price" gorm:"default:0"` + CallPrice float64 `json:"call_price" gorm:"default:0"` + Currency string `json:"currency" gorm:"size:16;default:'USD'"` + PricingType string `json:"pricing_type" gorm:"size:32;default:'token'"` + Status string `json:"status" gorm:"size:32;default:'draft';index"` + EffectiveAt int64 `json:"effective_at" gorm:"bigint;default:0"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +type ModelReviewRecord struct { + Id int `json:"id"` + ModelId int `json:"model_id" gorm:"not null;index"` + ReviewerId int `json:"reviewer_id" gorm:"index"` + Action string `json:"action" gorm:"size:64;not null"` + Comment string `json:"comment,omitempty" gorm:"type:text"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` +} + +type MarketplaceModelDetail struct { + MarketplaceModel + ApiConfigs []ModelApiConfig `json:"api_configs"` + Keys []ModelKey `json:"keys"` + Pricing []ModelPricing `json:"pricing"` + Reviews []ModelReviewRecord `json:"reviews"` + Wallet *ProviderWallet `json:"wallet,omitempty"` + Settlement *ProviderSettlementConfig `json:"settlement,omitempty"` +} + +func (profile *ProviderProfile) BeforeCreate(_ *gorm.DB) error { + now := common.GetTimestamp() + profile.CreatedAt = now + profile.UpdatedAt = now + return nil +} + +func (profile *ProviderProfile) BeforeUpdate(_ *gorm.DB) error { + profile.UpdatedAt = common.GetTimestamp() + return nil +} + +func (wallet *ProviderWallet) BeforeCreate(_ *gorm.DB) error { + now := common.GetTimestamp() + wallet.CreatedAt = now + wallet.UpdatedAt = now + if wallet.Currency == "" { + wallet.Currency = "USDT" + } + wallet.WalletAddressMask = common.MaskSecret(wallet.WalletAddress) + return nil +} + +func (wallet *ProviderWallet) BeforeUpdate(_ *gorm.DB) error { + wallet.UpdatedAt = common.GetTimestamp() + wallet.WalletAddressMask = common.MaskSecret(wallet.WalletAddress) + return nil +} + +func (config *ProviderSettlementConfig) BeforeCreate(_ *gorm.DB) error { + now := common.GetTimestamp() + config.CreatedAt = now + config.UpdatedAt = now + if config.Currency == "" { + config.Currency = "USDT" + } + if config.UsdtRate == 0 { + config.UsdtRate = 1 + } + return nil +} + +func (config *ProviderSettlementConfig) BeforeUpdate(_ *gorm.DB) error { + config.UpdatedAt = common.GetTimestamp() + return nil +} + +func (m *MarketplaceModel) BeforeCreate(_ *gorm.DB) error { + now := common.GetTimestamp() + m.CreatedAt = now + m.UpdatedAt = now + if m.Status == "" { + m.Status = MarketplaceModelStatusDraft + } + return nil +} + +func (m *MarketplaceModel) BeforeUpdate(_ *gorm.DB) error { + m.UpdatedAt = common.GetTimestamp() + return nil +} + +func (config *ModelApiConfig) BeforeCreate(_ *gorm.DB) error { + now := common.GetTimestamp() + config.CreatedAt = now + config.UpdatedAt = now + if config.Protocol == "" { + config.Protocol = "openai" + } + if config.AuthType == "" { + config.AuthType = "bearer" + } + if config.Status == "" { + config.Status = "active" + } + return nil +} + +func (config *ModelApiConfig) BeforeUpdate(_ *gorm.DB) error { + config.UpdatedAt = common.GetTimestamp() + return nil +} + +func (key *ModelKey) BeforeCreate(_ *gorm.DB) error { + now := common.GetTimestamp() + key.CreatedAt = now + key.UpdatedAt = now + if key.Status == "" { + key.Status = "active" + } + return nil +} + +func (key *ModelKey) BeforeUpdate(_ *gorm.DB) error { + key.UpdatedAt = common.GetTimestamp() + return nil +} + +func (pricing *ModelPricing) BeforeCreate(_ *gorm.DB) error { + now := common.GetTimestamp() + pricing.CreatedAt = now + pricing.UpdatedAt = now + if pricing.Currency == "" { + pricing.Currency = "USD" + } + if pricing.PricingType == "" { + pricing.PricingType = "token" + } + if pricing.Status == "" { + pricing.Status = "draft" + } + return nil +} + +func (pricing *ModelPricing) BeforeUpdate(_ *gorm.DB) error { + pricing.UpdatedAt = common.GetTimestamp() + return nil +} + +func (record *ModelReviewRecord) BeforeCreate(_ *gorm.DB) error { + record.CreatedAt = common.GetTimestamp() + return nil +} + +func SetModelKeyPlaintext(key *ModelKey, plaintext string) error { + plaintext = strings.TrimSpace(plaintext) + if plaintext == "" { + return errors.New("model key is required") + } + ciphertext, err := common.EncryptModelKey(plaintext) + if err != nil { + return err + } + key.KeyCipher = ciphertext + key.KeyMask = common.MaskSecret(plaintext) + return nil +} + +func GetProviderProfileByUserId(userId int) (*ProviderProfile, error) { + var profile ProviderProfile + if err := DB.Where("user_id = ?", userId).First(&profile).Error; err != nil { + return nil, err + } + return &profile, nil +} + +func EnsureProviderFinancialRows(providerId int) error { + now := common.GetTimestamp() + wallet := ProviderWallet{ProviderId: providerId, Currency: "USDT", CreatedAt: now, UpdatedAt: now} + if err := DB.Where("provider_id = ?", providerId).FirstOrCreate(&wallet).Error; err != nil { + return err + } + settlement := ProviderSettlementConfig{ProviderId: providerId, Currency: "USDT", UsdtRate: 1, CreatedAt: now, UpdatedAt: now} + return DB.Where("provider_id = ?", providerId).FirstOrCreate(&settlement).Error +} + +func ProviderOwnsModel(providerId int, modelId int) (bool, error) { + var count int64 + err := DB.Model(&MarketplaceModel{}).Where("id = ? AND provider_id = ?", modelId, providerId).Count(&count).Error + return count > 0, err +} + +func ListProviderProfiles(keyword string, offset int, limit int) ([]ProviderProfile, int64, error) { + query := DB.Model(&ProviderProfile{}) + if keyword != "" { + like := "%" + keyword + "%" + query = query.Where("name LIKE ? OR contact LIKE ? OR description LIKE ?", like, like, like) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var profiles []ProviderProfile + err := query.Order("id desc").Offset(offset).Limit(limit).Find(&profiles).Error + return profiles, total, err +} + +func ListMarketplaceModels(keyword string, providerId int, listedOnly bool, offset int, limit int) ([]MarketplaceModel, int64, error) { + query := DB.Model(&MarketplaceModel{}) + if keyword != "" { + like := "%" + keyword + "%" + query = query.Where("name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like) + } + if providerId > 0 { + query = query.Where("provider_id = ?", providerId) + } + if listedOnly { + query = query.Where("status = ?", MarketplaceModelStatusListed) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var models []MarketplaceModel + if err := query.Order("sort_order desc, id desc").Offset(offset).Limit(limit).Find(&models).Error; err != nil { + return nil, 0, err + } + if len(models) == 0 { + return models, total, nil + } + providerIds := make([]int, 0, len(models)) + for _, item := range models { + providerIds = append(providerIds, item.ProviderId) + } + var providers []ProviderProfile + if err := DB.Where("id IN ?", providerIds).Find(&providers).Error; err != nil { + return nil, 0, err + } + providerMap := map[int]*ProviderProfile{} + for i := range providers { + provider := providers[i] + providerMap[provider.Id] = &provider + } + for i := range models { + models[i].Provider = providerMap[models[i].ProviderId] + } + return models, total, nil +} + +func GetMarketplaceModelDetail(id int) (*MarketplaceModelDetail, error) { + var item MarketplaceModel + if err := DB.First(&item, id).Error; err != nil { + return nil, err + } + detail := MarketplaceModelDetail{MarketplaceModel: item} + var provider ProviderProfile + if err := DB.First(&provider, item.ProviderId).Error; err == nil { + detail.Provider = &provider + } + DB.Where("model_id = ?", id).Order("id desc").Find(&detail.ApiConfigs) + DB.Where("model_id = ?", id).Order("id desc").Find(&detail.Keys) + DB.Where("model_id = ?", id).Order("id desc").Find(&detail.Pricing) + DB.Where("model_id = ?", id).Order("id desc").Find(&detail.Reviews) + var wallet ProviderWallet + if err := DB.Where("provider_id = ?", item.ProviderId).First(&wallet).Error; err == nil { + detail.Wallet = &wallet + } + var settlement ProviderSettlementConfig + if err := DB.Where("provider_id = ?", item.ProviderId).First(&settlement).Error; err == nil { + detail.Settlement = &settlement + } + return &detail, nil +} diff --git a/model/rbac.go b/model/rbac.go new file mode 100644 index 000000000000..7ac11d412f5a --- /dev/null +++ b/model/rbac.go @@ -0,0 +1,327 @@ +package model + +import ( + "errors" + "strings" + + "github.com/QuantumNous/new-api/common" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +const ( + PlatformRoleSuperAdmin = "super_admin" + PlatformRoleOperator = "operator" + PlatformRoleFinance = "finance" + PlatformRoleModelProvider = "model_provider" + PlatformRoleUser = "user" +) + +const ( + PermissionRBACManage = "rbac.manage" + PermissionProviderManage = "provider.manage" + PermissionProviderSelfManage = "provider.self.manage" + PermissionMarketplaceManage = "marketplace.manage" + PermissionMarketplaceSelfManage = "marketplace.self.manage" + PermissionMarketplaceView = "marketplace.view" + PermissionMarketplaceKeyManage = "marketplace.key.manage" + PermissionMarketplaceSelfKeyManage = "marketplace.self.key.manage" + PermissionFinanceManage = "finance.manage" + PermissionFinanceView = "finance.view" + PermissionAuditView = "audit.view" +) + +type Role struct { + Id int `json:"id"` + Code string `json:"code" gorm:"size:64;not null;uniqueIndex"` + Name string `json:"name" gorm:"size:128;not null"` + Description string `json:"description,omitempty" gorm:"type:text"` + Builtin bool `json:"builtin"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +type Permission struct { + Id int `json:"id"` + Code string `json:"code" gorm:"size:96;not null;uniqueIndex"` + Name string `json:"name" gorm:"size:128;not null"` + Description string `json:"description,omitempty" gorm:"type:text"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` + UpdatedAt int64 `json:"updated_at" gorm:"bigint"` +} + +type UserRole struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"not null;index;uniqueIndex:uk_user_role,priority:1"` + RoleCode string `json:"role_code" gorm:"size:64;not null;index;uniqueIndex:uk_user_role,priority:2"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` +} + +type RolePermission struct { + Id int `json:"id"` + RoleCode string `json:"role_code" gorm:"size:64;not null;index;uniqueIndex:uk_role_permission,priority:1"` + PermissionCode string `json:"permission_code" gorm:"size:96;not null;index;uniqueIndex:uk_role_permission,priority:2"` + CreatedAt int64 `json:"created_at" gorm:"bigint"` +} + +type RoleWithPermissions struct { + Role + Permissions []string `json:"permissions" gorm:"-"` +} + +var builtinRoles = []Role{ + {Code: PlatformRoleSuperAdmin, Name: "Super Admin", Description: "Full platform access", Builtin: true}, + {Code: PlatformRoleOperator, Name: "Operator", Description: "Model operations and user usage management", Builtin: true}, + {Code: PlatformRoleFinance, Name: "Finance", Description: "Financial records, earnings, and settlement management", Builtin: true}, + {Code: PlatformRoleModelProvider, Name: "Model Provider", Description: "Provider-owned models, keys, pricing, and wallet", Builtin: true}, + {Code: PlatformRoleUser, Name: "User", Description: "Marketplace access and own account resources", Builtin: true}, +} + +var builtinPermissions = []Permission{ + {Code: PermissionRBACManage, Name: "Manage roles and permissions"}, + {Code: PermissionProviderManage, Name: "Manage all provider profiles"}, + {Code: PermissionProviderSelfManage, Name: "Manage own provider profile"}, + {Code: PermissionMarketplaceManage, Name: "Manage all marketplace models"}, + {Code: PermissionMarketplaceSelfManage, Name: "Manage own marketplace models"}, + {Code: PermissionMarketplaceView, Name: "View model marketplace"}, + {Code: PermissionMarketplaceKeyManage, Name: "Manage all model keys"}, + {Code: PermissionMarketplaceSelfKeyManage, Name: "Manage own model keys"}, + {Code: PermissionFinanceManage, Name: "Manage financial settlement data"}, + {Code: PermissionFinanceView, Name: "View financial settlement data"}, + {Code: PermissionAuditView, Name: "View audit logs"}, +} + +var builtinRolePermissions = map[string][]string{ + PlatformRoleSuperAdmin: { + PermissionRBACManage, + PermissionProviderManage, + PermissionProviderSelfManage, + PermissionMarketplaceManage, + PermissionMarketplaceSelfManage, + PermissionMarketplaceView, + PermissionMarketplaceKeyManage, + PermissionMarketplaceSelfKeyManage, + PermissionFinanceManage, + PermissionFinanceView, + PermissionAuditView, + }, + PlatformRoleOperator: { + PermissionProviderManage, + PermissionMarketplaceManage, + PermissionMarketplaceView, + PermissionMarketplaceKeyManage, + PermissionAuditView, + }, + PlatformRoleFinance: { + PermissionProviderManage, + PermissionMarketplaceView, + PermissionFinanceManage, + PermissionFinanceView, + PermissionAuditView, + }, + PlatformRoleModelProvider: { + PermissionProviderSelfManage, + PermissionMarketplaceSelfManage, + PermissionMarketplaceView, + PermissionMarketplaceSelfKeyManage, + PermissionFinanceView, + }, + PlatformRoleUser: { + PermissionMarketplaceView, + }, +} + +func EnsureBuiltinRBAC() error { + now := common.GetTimestamp() + for _, role := range builtinRoles { + role.CreatedAt = now + role.UpdatedAt = now + if err := DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "code"}}, + DoUpdates: clause.AssignmentColumns([]string{"name", "description", "builtin", "updated_at"}), + }).Create(&role).Error; err != nil { + return err + } + } + for _, permission := range builtinPermissions { + permission.CreatedAt = now + permission.UpdatedAt = now + if err := DB.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "code"}}, + DoUpdates: clause.AssignmentColumns([]string{"name", "description", "updated_at"}), + }).Create(&permission).Error; err != nil { + return err + } + } + for roleCode, permissions := range builtinRolePermissions { + for _, permissionCode := range permissions { + rp := RolePermission{RoleCode: roleCode, PermissionCode: permissionCode, CreatedAt: now} + if err := DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&rp).Error; err != nil { + return err + } + } + } + return nil +} + +func BuiltinRoleForLegacyRole(role int) string { + switch { + case role >= common.RoleRootUser: + return PlatformRoleSuperAdmin + case role >= common.RoleAdminUser: + return PlatformRoleOperator + default: + return PlatformRoleUser + } +} + +func GetUserRoleCodes(userId int, legacyRole int) ([]string, error) { + roleSet := map[string]struct{}{ + BuiltinRoleForLegacyRole(legacyRole): {}, + } + var rows []UserRole + if err := DB.Where("user_id = ?", userId).Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + if row.RoleCode != "" { + roleSet[row.RoleCode] = struct{}{} + } + } + roles := make([]string, 0, len(roleSet)) + for roleCode := range roleSet { + roles = append(roles, roleCode) + } + return roles, nil +} + +func GetUserPermissionCodes(userId int, legacyRole int) ([]string, error) { + if legacyRole >= common.RoleRootUser { + permissions := make([]string, 0, len(builtinPermissions)) + for _, permission := range builtinPermissions { + permissions = append(permissions, permission.Code) + } + return permissions, nil + } + roleCodes, err := GetUserRoleCodes(userId, legacyRole) + if err != nil { + return nil, err + } + var rows []RolePermission + if err := DB.Where("role_code IN ?", roleCodes).Find(&rows).Error; err != nil { + return nil, err + } + permissionSet := map[string]struct{}{} + for _, row := range rows { + permissionSet[row.PermissionCode] = struct{}{} + } + permissions := make([]string, 0, len(permissionSet)) + for permissionCode := range permissionSet { + permissions = append(permissions, permissionCode) + } + return permissions, nil +} + +func UserHasPermission(userId int, legacyRole int, permissionCode string) (bool, error) { + if legacyRole >= common.RoleRootUser { + return true, nil + } + permissions, err := GetUserPermissionCodes(userId, legacyRole) + if err != nil { + return false, err + } + for _, permission := range permissions { + if permission == permissionCode { + return true, nil + } + } + return false, nil +} + +func UserHasAnyPermission(userId int, legacyRole int, permissions ...string) (bool, error) { + if len(permissions) == 0 { + return true, nil + } + for _, permission := range permissions { + ok, err := UserHasPermission(userId, legacyRole, permission) + if err != nil || ok { + return ok, err + } + } + return false, nil +} + +func ListRolesWithPermissions() ([]RoleWithPermissions, error) { + var roles []Role + if err := DB.Order("id asc").Find(&roles).Error; err != nil { + return nil, err + } + var rolePermissions []RolePermission + if err := DB.Order("role_code asc, permission_code asc").Find(&rolePermissions).Error; err != nil { + return nil, err + } + permissionMap := map[string][]string{} + for _, rp := range rolePermissions { + permissionMap[rp.RoleCode] = append(permissionMap[rp.RoleCode], rp.PermissionCode) + } + result := make([]RoleWithPermissions, 0, len(roles)) + for _, role := range roles { + result = append(result, RoleWithPermissions{ + Role: role, + Permissions: permissionMap[role.Code], + }) + } + return result, nil +} + +func ListPermissions() ([]Permission, error) { + var permissions []Permission + err := DB.Order("code asc").Find(&permissions).Error + return permissions, err +} + +func ListUserRoles(userId int) ([]UserRole, error) { + var roles []UserRole + err := DB.Where("user_id = ?", userId).Order("role_code asc").Find(&roles).Error + return roles, err +} + +func ReplaceUserRoles(userId int, roleCodes []string) error { + now := common.GetTimestamp() + normalized := make([]string, 0, len(roleCodes)) + seen := map[string]struct{}{} + for _, roleCode := range roleCodes { + roleCode = strings.TrimSpace(roleCode) + if roleCode == "" { + continue + } + if _, ok := seen[roleCode]; ok { + continue + } + seen[roleCode] = struct{}{} + normalized = append(normalized, roleCode) + } + return DB.Transaction(func(tx *gorm.DB) error { + if len(normalized) > 0 { + var count int64 + if err := tx.Model(&Role{}).Where("code IN ?", normalized).Count(&count).Error; err != nil { + return err + } + if count != int64(len(normalized)) { + return errors.New("role code does not exist") + } + } + if err := tx.Where("user_id = ?", userId).Delete(&UserRole{}).Error; err != nil { + return err + } + for _, roleCode := range normalized { + userRole := UserRole{UserId: userId, RoleCode: roleCode, CreatedAt: now} + if err := tx.Create(&userRole).Error; err != nil { + return err + } + } + return nil + }) +} diff --git a/model/rbac_marketplace_test.go b/model/rbac_marketplace_test.go new file mode 100644 index 000000000000..1da06714018e --- /dev/null +++ b/model/rbac_marketplace_test.go @@ -0,0 +1,183 @@ +package model + +import ( + "os" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuiltinRBACMapsLegacyRoles(t *testing.T) { + truncateTables(t) + + rootPermissions, err := GetUserPermissionCodes(1, common.RoleRootUser) + require.NoError(t, err) + assert.Contains(t, rootPermissions, PermissionRBACManage) + assert.Contains(t, rootPermissions, PermissionFinanceManage) + + adminPermissions, err := GetUserPermissionCodes(2, common.RoleAdminUser) + require.NoError(t, err) + assert.Contains(t, adminPermissions, PermissionMarketplaceManage) + assert.NotContains(t, adminPermissions, PermissionFinanceManage) + + userPermissions, err := GetUserPermissionCodes(3, common.RoleCommonUser) + require.NoError(t, err) + assert.Equal(t, []string{PermissionMarketplaceView}, userPermissions) +} + +func TestUserRoleBindingAddsFinancePermission(t *testing.T) { + truncateTables(t) + require.NoError(t, ReplaceUserRoles(10, []string{PlatformRoleFinance})) + + ok, err := UserHasPermission(10, common.RoleCommonUser, PermissionFinanceManage) + + require.NoError(t, err) + assert.True(t, ok) +} + +func TestRBACPermissionMatrixForPhaseOneRoles(t *testing.T) { + truncateTables(t) + cases := []struct { + name string + userId int + legacyRole int + roleCodes []string + allowed []string + notAllowed []string + anyAllowed []string + anyRejected []string + }{ + { + name: "operator", + userId: 21, + legacyRole: common.RoleCommonUser, + roleCodes: []string{PlatformRoleOperator}, + allowed: []string{ + PermissionProviderManage, + PermissionMarketplaceManage, + PermissionMarketplaceKeyManage, + PermissionAuditView, + }, + notAllowed: []string{PermissionFinanceManage}, + }, + { + name: "finance", + userId: 22, + legacyRole: common.RoleCommonUser, + roleCodes: []string{PlatformRoleFinance}, + allowed: []string{ + PermissionProviderManage, + PermissionFinanceManage, + PermissionFinanceView, + PermissionAuditView, + }, + notAllowed: []string{PermissionMarketplaceKeyManage}, + }, + { + name: "provider", + userId: 23, + legacyRole: common.RoleCommonUser, + roleCodes: []string{PlatformRoleModelProvider}, + allowed: []string{ + PermissionProviderSelfManage, + PermissionMarketplaceSelfManage, + PermissionMarketplaceSelfKeyManage, + PermissionFinanceView, + }, + notAllowed: []string{ + PermissionProviderManage, + PermissionFinanceManage, + PermissionMarketplaceKeyManage, + }, + }, + { + name: "user", + userId: 24, + legacyRole: common.RoleCommonUser, + allowed: []string{PermissionMarketplaceView}, + notAllowed: []string{ + PermissionProviderSelfManage, + PermissionMarketplaceSelfManage, + PermissionFinanceView, + PermissionAuditView, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if len(tc.roleCodes) > 0 { + require.NoError(t, ReplaceUserRoles(tc.userId, tc.roleCodes)) + } + for _, permission := range tc.allowed { + ok, err := UserHasPermission(tc.userId, tc.legacyRole, permission) + require.NoError(t, err) + assert.True(t, ok, permission) + } + for _, permission := range tc.notAllowed { + ok, err := UserHasPermission(tc.userId, tc.legacyRole, permission) + require.NoError(t, err) + assert.False(t, ok, permission) + } + }) + } +} + +func TestProviderOwnsModelEnforcesDataIsolation(t *testing.T) { + truncateTables(t) + provider := ProviderProfile{UserId: 11, Name: "provider-a"} + require.NoError(t, DB.Create(&provider).Error) + other := ProviderProfile{UserId: 12, Name: "provider-b"} + require.NoError(t, DB.Create(&other).Error) + item := MarketplaceModel{ProviderId: provider.Id, Name: "model-a"} + require.NoError(t, DB.Create(&item).Error) + + owns, err := ProviderOwnsModel(provider.Id, item.Id) + require.NoError(t, err) + assert.True(t, owns) + + owns, err = ProviderOwnsModel(other.Id, item.Id) + require.NoError(t, err) + assert.False(t, owns) +} + +func TestModelKeyEncryptionAndMasking(t *testing.T) { + truncateTables(t) + t.Setenv("MODEL_KEY_ENCRYPTION_SECRET", "test-secret") + + key := ModelKey{ModelId: 1, Name: "primary"} + require.NoError(t, SetModelKeyPlaintext(&key, "sk-secret-value")) + require.NoError(t, DB.Create(&key).Error) + + assert.NotEqual(t, "sk-secret-value", key.KeyCipher) + assert.Equal(t, "sk-s****alue", key.KeyMask) + + var stored ModelKey + require.NoError(t, DB.First(&stored, key.Id).Error) + responseBody, err := common.Marshal(stored) + require.NoError(t, err) + assert.NotContains(t, string(responseBody), "key_cipher") + assert.False(t, strings.Contains(string(responseBody), "sk-secret-value")) + + plaintext, err := common.DecryptModelKey(stored.KeyCipher) + require.NoError(t, err) + assert.Equal(t, "sk-secret-value", plaintext) +} + +func TestModelKeyEncryptionRequiresDedicatedSecret(t *testing.T) { + original, hadOriginal := os.LookupEnv("MODEL_KEY_ENCRYPTION_SECRET") + require.NoError(t, os.Unsetenv("MODEL_KEY_ENCRYPTION_SECRET")) + t.Cleanup(func() { + if hadOriginal { + require.NoError(t, os.Setenv("MODEL_KEY_ENCRYPTION_SECRET", original)) + } + }) + + key := ModelKey{ModelId: 1, Name: "primary"} + err := SetModelKeyPlaintext(&key, "sk-secret-value") + + require.ErrorIs(t, err, common.ErrModelKeyEncryptionSecretMissing) +} diff --git a/model/task_cas_test.go b/model/task_cas_test.go index f8288656e443..86e4d695cdcb 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -49,9 +49,24 @@ func TestMain(m *testing.M) { &UserOAuthBinding{}, &PerfMetric{}, &SystemTask{}, + &Role{}, + &Permission{}, + &UserRole{}, + &RolePermission{}, + &ProviderProfile{}, + &ProviderWallet{}, + &ProviderSettlementConfig{}, + &MarketplaceModel{}, + &ModelApiConfig{}, + &ModelKey{}, + &ModelPricing{}, + &ModelReviewRecord{}, ); err != nil { panic("failed to migrate: " + err.Error()) } + if err := EnsureBuiltinRBAC(); err != nil { + panic("failed to seed builtin RBAC: " + err.Error()) + } os.Exit(m.Run()) } @@ -73,6 +88,19 @@ func truncateTables(t *testing.T) { DB.Exec("DELETE FROM user_oauth_bindings") DB.Exec("DELETE FROM perf_metrics") DB.Exec("DELETE FROM system_tasks") + DB.Exec("DELETE FROM role_permissions") + DB.Exec("DELETE FROM user_roles") + DB.Exec("DELETE FROM permissions") + DB.Exec("DELETE FROM roles") + DB.Exec("DELETE FROM provider_profiles") + DB.Exec("DELETE FROM provider_wallets") + DB.Exec("DELETE FROM provider_settlement_configs") + DB.Exec("DELETE FROM marketplace_models") + DB.Exec("DELETE FROM model_api_configs") + DB.Exec("DELETE FROM model_keys") + DB.Exec("DELETE FROM model_pricings") + DB.Exec("DELETE FROM model_review_records") + require.NoError(t, EnsureBuiltinRBAC()) }) } diff --git a/router/api-router.go b/router/api-router.go index 63401967494d..47b0db4f8500 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -3,6 +3,7 @@ package router import ( "github.com/QuantumNous/new-api/controller" "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" // Import oauth package to register providers via init() _ "github.com/QuantumNous/new-api/oauth" @@ -303,14 +304,14 @@ func SetApiRouter(router *gin.Engine) { redemptionRoute.DELETE("/:id", controller.DeleteRedemption) } logRoute := apiRouter.Group("/log") - logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs) + logRoute.GET("/", middleware.UserAuth(), middleware.PermissionAuth(model.PermissionAuditView), controller.GetAllLogs) // Legacy synchronous direct-delete route used only by the classic frontend. // TODO: remove once the classic frontend is removed; the default frontend uses /system-task/log-cleanup. logRoute.DELETE("/", middleware.RootAuth(), controller.DeleteHistoryLogs) - logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat) + logRoute.GET("/stat", middleware.UserAuth(), middleware.PermissionAuth(model.PermissionAuditView), controller.GetLogsStat) logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat) - logRoute.GET("/channel_affinity_usage_cache", middleware.AdminAuth(), controller.GetChannelAffinityUsageCacheStats) - logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs) + logRoute.GET("/channel_affinity_usage_cache", middleware.UserAuth(), middleware.PermissionAuth(model.PermissionAuditView), controller.GetChannelAffinityUsageCacheStats) + logRoute.GET("/search", middleware.UserAuth(), middleware.PermissionAuth(model.PermissionAuditView), controller.SearchAllLogs) logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs) logRoute.GET("/self/search", middleware.UserAuth(), middleware.SearchRateLimit(), controller.SearchUserLogs) @@ -339,6 +340,44 @@ func SetApiRouter(router *gin.Engine) { groupRoute.GET("/", controller.GetGroups) } + rbacRoute := apiRouter.Group("/rbac") + rbacRoute.Use(middleware.UserAuth()) + { + rbacRoute.GET("/roles", middleware.PermissionAuth(model.PermissionRBACManage), controller.ListRBACRoles) + rbacRoute.GET("/permissions", middleware.PermissionAuth(model.PermissionRBACManage), controller.ListRBACPermissions) + rbacRoute.GET("/users/:id/roles", middleware.PermissionAuth(model.PermissionRBACManage), controller.ListRBACUserRoles) + rbacRoute.PUT("/users/:id/roles", middleware.PermissionAuth(model.PermissionRBACManage), controller.ReplaceRBACUserRoles) + } + + providerRoute := apiRouter.Group("/provider") + providerRoute.Use(middleware.UserAuth()) + { + providerRoute.GET("/", middleware.PermissionAuth(model.PermissionProviderManage, model.PermissionProviderSelfManage), controller.ListProviders) + providerRoute.POST("/", middleware.PermissionAuth(model.PermissionProviderManage, model.PermissionProviderSelfManage), controller.UpsertProvider) + providerRoute.GET("/:id", middleware.PermissionAuth(model.PermissionProviderManage, model.PermissionProviderSelfManage), controller.GetProvider) + providerRoute.PUT("/:id", middleware.PermissionAuth(model.PermissionProviderManage, model.PermissionProviderSelfManage), controller.UpsertProvider) + providerRoute.GET("/:id/wallet", middleware.PermissionAuth(model.PermissionFinanceView, model.PermissionFinanceManage, model.PermissionProviderSelfManage), controller.GetProviderWallet) + providerRoute.PUT("/:id/wallet", middleware.PermissionAuth(model.PermissionFinanceManage, model.PermissionProviderSelfManage), controller.UpdateProviderWallet) + providerRoute.GET("/:id/settlement", middleware.PermissionAuth(model.PermissionFinanceView, model.PermissionFinanceManage, model.PermissionProviderSelfManage), controller.GetProviderSettlement) + providerRoute.PUT("/:id/settlement", middleware.PermissionAuth(model.PermissionFinanceManage, model.PermissionProviderSelfManage), controller.UpdateProviderSettlement) + } + + marketplaceRoute := apiRouter.Group("/marketplace-models") + marketplaceRoute.Use(middleware.UserAuth()) + { + marketplaceRoute.GET("/", middleware.PermissionAuth(model.PermissionMarketplaceView), controller.ListMarketplaceModels) + marketplaceRoute.GET("/:id", middleware.PermissionAuth(model.PermissionMarketplaceView), controller.GetMarketplaceModel) + marketplaceRoute.POST("/", middleware.PermissionAuth(model.PermissionMarketplaceManage, model.PermissionMarketplaceSelfManage), controller.CreateMarketplaceModel) + marketplaceRoute.PUT("/:id", middleware.PermissionAuth(model.PermissionMarketplaceManage, model.PermissionMarketplaceSelfManage), controller.UpdateMarketplaceModel) + marketplaceRoute.DELETE("/:id", middleware.PermissionAuth(model.PermissionMarketplaceManage, model.PermissionMarketplaceSelfManage), controller.DeleteMarketplaceModel) + marketplaceRoute.POST("/:id/api-configs", middleware.PermissionAuth(model.PermissionMarketplaceManage, model.PermissionMarketplaceSelfManage), controller.UpsertModelApiConfig) + marketplaceRoute.POST("/:id/keys", middleware.PermissionAuth(model.PermissionMarketplaceKeyManage, model.PermissionMarketplaceSelfKeyManage), controller.CreateModelKey) + marketplaceRoute.PUT("/:id/keys/:key_id", middleware.PermissionAuth(model.PermissionMarketplaceKeyManage, model.PermissionMarketplaceSelfKeyManage), controller.UpdateModelKey) + marketplaceRoute.DELETE("/:id/keys/:key_id", middleware.PermissionAuth(model.PermissionMarketplaceKeyManage, model.PermissionMarketplaceSelfKeyManage), controller.DeleteModelKey) + marketplaceRoute.POST("/:id/pricing", middleware.PermissionAuth(model.PermissionMarketplaceManage, model.PermissionMarketplaceSelfManage), controller.UpsertModelPricing) + marketplaceRoute.POST("/:id/reviews", middleware.PermissionAuth(model.PermissionMarketplaceManage), controller.CreateModelReviewRecord) + } + prefillGroupRoute := apiRouter.Group("/prefill_group") prefillGroupRoute.Use(middleware.AdminAuth()) { diff --git a/web/default/src/features/secondary-development/api.ts b/web/default/src/features/secondary-development/api.ts new file mode 100644 index 000000000000..c06c5cc31f2e --- /dev/null +++ b/web/default/src/features/secondary-development/api.ts @@ -0,0 +1,203 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { api } from '@/lib/api' + +import type { + ApiResponse, + MarketplaceModel, + MarketplaceModelDetail, + ModelApiConfig, + ModelKey, + ModelPricing, + PageData, + Permission, + ProviderProfile, + ProviderSettlementConfig, + ProviderWallet, + Role, + UserRole, +} from './types' + +type PageParams = { + p?: number + page_size?: number + keyword?: string + provider_id?: number + listed_only?: boolean +} + +export async function getRoles(): Promise> { + const res = await api.get('/api/rbac/roles') + return res.data +} + +export async function getPermissions(): Promise> { + const res = await api.get('/api/rbac/permissions') + return res.data +} + +export async function getUserRoles( + userId: number +): Promise> { + const res = await api.get(`/api/rbac/users/${userId}/roles`) + return res.data +} + +export async function updateUserRoles( + userId: number, + roleCodes: string[] +): Promise> { + const res = await api.put(`/api/rbac/users/${userId}/roles`, { + role_codes: roleCodes, + }) + return res.data +} + +export async function getProviders( + params: PageParams = {} +): Promise>> { + const res = await api.get('/api/provider/', { params }) + return res.data +} + +export async function saveProvider( + provider: Partial +): Promise> { + const method = provider.id ? api.put : api.post + const url = provider.id ? `/api/provider/${provider.id}` : '/api/provider/' + const res = await method(url, provider) + return res.data +} + +export async function getProviderWallet( + providerId: number +): Promise> { + const res = await api.get(`/api/provider/${providerId}/wallet`) + return res.data +} + +export async function saveProviderWallet( + providerId: number, + wallet: Partial +): Promise> { + const res = await api.put(`/api/provider/${providerId}/wallet`, wallet) + return res.data +} + +export async function getProviderSettlement( + providerId: number +): Promise> { + const res = await api.get(`/api/provider/${providerId}/settlement`) + return res.data +} + +export async function saveProviderSettlement( + providerId: number, + settlement: Partial +): Promise> { + const res = await api.put( + `/api/provider/${providerId}/settlement`, + settlement + ) + return res.data +} + +export async function getMarketplaceModels( + params: PageParams = {} +): Promise>> { + const res = await api.get('/api/marketplace-models/', { params }) + return res.data +} + +export async function getMarketplaceModel( + id: number +): Promise> { + const res = await api.get(`/api/marketplace-models/${id}`) + return res.data +} + +export async function saveMarketplaceModel( + model: Partial +): Promise> { + const method = model.id ? api.put : api.post + const url = model.id + ? `/api/marketplace-models/${model.id}` + : '/api/marketplace-models/' + const res = await method(url, model) + return res.data +} + +export async function deleteMarketplaceModel( + id: number +): Promise> { + const res = await api.delete(`/api/marketplace-models/${id}`) + return res.data +} + +export async function saveModelApiConfig( + modelId: number, + config: Partial +): Promise> { + const res = await api.post( + `/api/marketplace-models/${modelId}/api-configs`, + config + ) + return res.data +} + +export async function createModelKey( + modelId: number, + key: { name: string; key: string; status?: string } +): Promise> { + const res = await api.post(`/api/marketplace-models/${modelId}/keys`, key) + return res.data +} + +export async function updateModelKey( + modelId: number, + keyId: number, + key: { name?: string; key?: string; status?: string } +): Promise> { + const res = await api.put( + `/api/marketplace-models/${modelId}/keys/${keyId}`, + key + ) + return res.data +} + +export async function deleteModelKey( + modelId: number, + keyId: number +): Promise> { + const res = await api.delete( + `/api/marketplace-models/${modelId}/keys/${keyId}` + ) + return res.data +} + +export async function saveModelPricing( + modelId: number, + pricing: Partial +): Promise> { + const res = await api.post( + `/api/marketplace-models/${modelId}/pricing`, + pricing + ) + return res.data +} diff --git a/web/default/src/features/secondary-development/index.tsx b/web/default/src/features/secondary-development/index.tsx new file mode 100644 index 000000000000..d9ecafcec5d4 --- /dev/null +++ b/web/default/src/features/secondary-development/index.tsx @@ -0,0 +1,1140 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Plus, RefreshCw, Save, Trash2 } from 'lucide-react' +import { type FormEvent, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +import { StaticDataTable } from '@/components/data-table' +import { SectionPageLayout } from '@/components/layout' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Field, FieldGroup, FieldLabel } from '@/components/ui/field' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { hasAnyPermission, PERMISSION } from '@/lib/rbac' +import { useAuthStore } from '@/stores/auth-store' + +import { + createModelKey, + deleteMarketplaceModel, + deleteModelKey, + getMarketplaceModel, + getMarketplaceModels, + getPermissions, + getProviders, + getProviderSettlement, + getProviderWallet, + getRoles, + getUserRoles, + saveMarketplaceModel, + saveModelApiConfig, + saveModelPricing, + saveProvider, + saveProviderSettlement, + saveProviderWallet, + updateModelKey, + updateUserRoles, +} from './api' +import type { + MarketplaceModel, + MarketplaceModelDetail, + ModelApiConfig, + ModelPricing, + ProviderProfile, + ProviderSettlementConfig, + ProviderWallet, + Role, +} from './types' + +type SecondaryDevelopmentProps = { + section: 'marketplace' | 'provider' | 'rbac' | 'finance' +} + +const modelDefaults: Partial = { + name: '', + description: '', + model_type: 'text', + tags: '', + context_length: 0, + billing_type: 'token', + status: 'draft', + recommended: false, + sort_order: 0, +} + +export function SecondaryDevelopment(props: SecondaryDevelopmentProps) { + if (props.section === 'rbac') return + if (props.section === 'finance') return + if (props.section === 'provider') return + return +} + +function MarketplaceConsole() { + const { t } = useTranslation() + const user = useAuthStore((s) => s.auth.user) + const canManage = hasAnyPermission(user, [ + PERMISSION.MARKETPLACE_MANAGE, + PERMISSION.MARKETPLACE_SELF_MANAGE, + ]) + const [selectedId, setSelectedId] = useState(null) + const [draft, setDraft] = useState>(modelDefaults) + const queryClient = useQueryClient() + const modelsQuery = useQuery({ + queryKey: ['secondary-marketplace-models'], + queryFn: () => getMarketplaceModels({ page_size: 50 }), + }) + const detailQuery = useQuery({ + queryKey: ['secondary-marketplace-model', selectedId], + queryFn: () => getMarketplaceModel(selectedId || 0), + enabled: Boolean(selectedId), + }) + const saveMutation = useMutation({ + mutationFn: saveMarketplaceModel, + onSuccess: (result) => { + if (result.success) { + toast.success(t('Saved successfully')) + setDraft(modelDefaults) + queryClient.invalidateQueries({ + queryKey: ['secondary-marketplace-models'], + }) + } + }, + }) + const deleteMutation = useMutation({ + mutationFn: deleteMarketplaceModel, + onSuccess: (result) => { + if (result.success) { + toast.success(t('Deleted successfully')) + setSelectedId(null) + queryClient.invalidateQueries({ + queryKey: ['secondary-marketplace-models'], + }) + } + }, + }) + const items = modelsQuery.data?.data?.items ?? [] + + const handleEdit = (item: MarketplaceModel) => { + setSelectedId(item.id) + setDraft(item) + } + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + saveMutation.mutate({ + ...draft, + context_length: Number(draft.context_length || 0), + sort_order: Number(draft.sort_order || 0), + }) + } + + return ( + + + {t('Model Marketplace')} + + + + + +
+ + + {t('Marketplace Models')} + + {t('Approved models can later be listed for users.')} + + + + row.name }, + { + id: 'provider', + header: t('Provider'), + cell: (row) => row.provider?.name || `#${row.provider_id}`, + }, + { + id: 'status', + header: t('Status'), + cell: (row) => ( + {row.status} + ), + }, + { + id: 'actions', + header: t('Actions'), + cell: (row) => ( +
+ + {canManage && ( + + )} +
+ ), + }, + ]} + /> +
+
+
+ {canManage && ( + + )} + {detailQuery.data?.success && detailQuery.data.data && ( + + )} +
+
+
+
+ ) +} + +function MarketplaceModelForm(props: { + draft: Partial + onChange: (draft: Partial) => void + onSubmit: (event: FormEvent) => void + isSubmitting: boolean +}) { + const { t } = useTranslation() + const update = (patch: Partial) => + props.onChange({ ...props.draft, ...patch }) + + return ( + + + + {props.draft.id ? t('Update Model') : t('Create Model')} + + + {t('Maintain the phase one model foundation data.')} + + + +
+ + + {t('Model Name')} + update({ name: event.target.value })} + /> + + + {t('Description')} +