fix(access): 统一 API Key 模型权限与列表契约
将全局启用、用户组基线、API Key 专属或排除规则及 scope 按固定顺序求值,避免 Key 越过所属用户组权限,并让运行时候选与模型列表共用同一权限链。 新增 Key 级可分配模型与失效规则诊断接口、OpenAI 兼容 /v1/models 及 rich 列表迁移路径;前端权限弹窗改为按当前 Key 实时加载并支持清理失效规则。 验证:Go 全量测试与 go vet 通过;Web 22 个测试文件共 142 项通过;pnpm lint、pnpm openapi、pnpm build、Compose 配置、gofmt、ShellCheck 和 git diff --check 通过;独立 PostgreSQL 真实配置验收通过。
This commit is contained in:
@@ -64,12 +64,14 @@ func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
|
||||
// @Tags api-keys
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Deprecated
|
||||
// @Success 200 {object} PlatformModelListResponse
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/api-keys/assignable-models [get]
|
||||
func (s *Server) listAPIKeyAssignableModels(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Deprecation", "true")
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
models, err := s.store.ListAPIKeyAssignablePlatformModels(r.Context(), user)
|
||||
if err != nil {
|
||||
@@ -84,6 +86,41 @@ func (s *Server) listAPIKeyAssignableModels(w http.ResponseWriter, r *http.Reque
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": s.platformModelResponses(r.Context(), models)})
|
||||
}
|
||||
|
||||
// listAPIKeyAssignableModelsForKey godoc
|
||||
// @Summary 列出指定 API Key 可分配模型
|
||||
// @Description 返回指定 API Key 所属用户组允许、全局启用且符合 KEY scope 的平台来源,并附带已有规则有效性诊断。
|
||||
// @Tags api-keys
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param apiKeyID path string true "API Key ID"
|
||||
// @Success 200 {object} APIKeyAssignableModelsResponse
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 503 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/api-keys/{apiKeyID}/assignable-models [get]
|
||||
func (s *Server) listAPIKeyAssignableModelsForKey(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
models, diagnostics, err := s.store.ListAPIKeyAssignablePlatformModelsForKey(r.Context(), user, r.PathValue("apiKeyID"))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrLocalUserRequired) {
|
||||
writeLocalUserRequired(w)
|
||||
return
|
||||
}
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "api key not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("list api key assignable models failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list api key assignable models failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, APIKeyAssignableModelsResponse{
|
||||
Items: s.platformModelResponses(r.Context(), models),
|
||||
RuleDiagnostics: diagnostics,
|
||||
})
|
||||
}
|
||||
|
||||
// createAccessRule godoc
|
||||
// @Summary 创建访问规则
|
||||
// @Description 管理端创建一条访问控制规则。
|
||||
@@ -192,7 +229,7 @@ func (s *Server) batchAPIKeyAccessRules(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrAccessRuleResourceDenied) {
|
||||
writeError(w, http.StatusForbidden, "resource is not available for current user group")
|
||||
writeError(w, http.StatusForbidden, "resource is not available for current user group or API key scope")
|
||||
return
|
||||
}
|
||||
s.logger.Error("batch api key access rules failed", "error", err)
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type modelAccessFixture struct {
|
||||
ID string `json:"id"`
|
||||
ModelName string `json:"modelName"`
|
||||
ModelType []string `json:"modelType"`
|
||||
}
|
||||
|
||||
type modelAccessRuleDiagnostic struct {
|
||||
RuleID string `json:"ruleId"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
Effective bool `json:"effective"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type modelAccessAssignableResponse struct {
|
||||
Items []modelAccessFixture `json:"items"`
|
||||
RuleDiagnostics []modelAccessRuleDiagnostic `json:"ruleDiagnostics"`
|
||||
}
|
||||
|
||||
func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the API key model access integration flow")
|
||||
}
|
||||
ctx := context.Background()
|
||||
applyMigration(t, ctx, databaseURL)
|
||||
db, err := store.Connect(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect store: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
serverCtx, cancelServer := context.WithCancel(ctx)
|
||||
defer cancelServer()
|
||||
server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
|
||||
AppEnv: "test",
|
||||
HTTPAddr: ":0",
|
||||
DatabaseURL: databaseURL,
|
||||
IdentityMode: "hybrid",
|
||||
JWTSecret: "test-secret",
|
||||
CORSAllowedOrigin: "*",
|
||||
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||
defer server.Close()
|
||||
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect test pool: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
register := func(prefix string) (string, string) {
|
||||
t.Helper()
|
||||
username := prefix + "_" + suffix
|
||||
password := "password123"
|
||||
var registered struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||
"username": username,
|
||||
"email": username + "@example.com",
|
||||
"password": password,
|
||||
}, http.StatusCreated, ®istered)
|
||||
return username, password
|
||||
}
|
||||
login := func(username string, password string) string {
|
||||
t.Helper()
|
||||
var response struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||
"account": username, "password": password,
|
||||
}, http.StatusOK, &response)
|
||||
return response.AccessToken
|
||||
}
|
||||
adminName, adminPassword := register("layered_admin")
|
||||
userBName, userBPassword := register("layered_user_b")
|
||||
if _, err := pool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, adminName); err != nil {
|
||||
t.Fatalf("promote admin: %v", err)
|
||||
}
|
||||
adminToken := login(adminName, adminPassword)
|
||||
|
||||
createGroup := func(key string) string {
|
||||
t.Helper()
|
||||
var group struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/user-groups", adminToken, map[string]any{
|
||||
"groupKey": key + "-" + suffix,
|
||||
"name": key,
|
||||
"source": "gateway",
|
||||
"status": "active",
|
||||
}, http.StatusCreated, &group)
|
||||
return group.ID
|
||||
}
|
||||
groupAID := createGroup("layered-group-a")
|
||||
groupBID := createGroup("layered-group-b")
|
||||
if _, err := pool.Exec(ctx, `UPDATE gateway_users SET default_user_group_id = $1::uuid WHERE username = $2`, groupAID, adminName); err != nil {
|
||||
t.Fatalf("assign group A: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE gateway_users SET default_user_group_id = $1::uuid WHERE username = $2`, groupBID, userBName); err != nil {
|
||||
t.Fatalf("assign group B: %v", err)
|
||||
}
|
||||
adminToken = login(adminName, adminPassword)
|
||||
userBToken := login(userBName, userBPassword)
|
||||
|
||||
createKey := func(token string, name string, scopes []string) (string, string) {
|
||||
t.Helper()
|
||||
var created struct {
|
||||
APIKey struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"apiKey"`
|
||||
Secret string `json:"secret"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", token, map[string]any{
|
||||
"name": name, "scopes": scopes,
|
||||
}, http.StatusCreated, &created)
|
||||
return created.APIKey.ID, created.Secret
|
||||
}
|
||||
keyAID, keyASecret := createKey(adminToken, "group A image key", []string{"image"})
|
||||
keyAInheritedID, keyAInheritedSecret := createKey(adminToken, "group A inherited image key", []string{"image"})
|
||||
keyMultiID, keyMultiSecret := createKey(adminToken, "group A multi-source image key", []string{"image"})
|
||||
keyScopeID, _ := createKey(adminToken, "group A narrowed scope key", []string{"all"})
|
||||
keyLegacyID, _ := createKey(adminToken, "group A narrowed group key", []string{"all"})
|
||||
keyBID, keyBSecret := createKey(userBToken, "group B chat key", []string{"chat"})
|
||||
keyBImageID, keyBImageSecret := createKey(userBToken, "group B image key", []string{"image"})
|
||||
|
||||
createSimulationBaseModel(t, server.URL, adminToken, "layered-text-"+suffix, []string{"text_generate"})
|
||||
createSimulationBaseModel(t, server.URL, adminToken, "layered-image-"+suffix, []string{"image_generate"})
|
||||
createSimulationBaseModel(t, server.URL, adminToken, "layered-multi-"+suffix, []string{"text_generate", "image_generate"})
|
||||
createSimulationBaseModel(t, server.URL, adminToken, "layered-key-deny-image-"+suffix, []string{"image_generate"})
|
||||
createSimulationBaseModel(t, server.URL, adminToken, "layered-group-b-image-"+suffix, []string{"image_generate"})
|
||||
createSimulationBaseModel(t, server.URL, adminToken, "layered-scope-text-"+suffix, []string{"text_generate"})
|
||||
createSimulationBaseModel(t, server.URL, adminToken, "layered-legacy-text-"+suffix, []string{"text_generate"})
|
||||
|
||||
createPlatform := func(key string, status string) string {
|
||||
t.Helper()
|
||||
var platform struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", adminToken, map[string]any{
|
||||
"provider": "openai",
|
||||
"platformKey": key + "-" + suffix,
|
||||
"name": key,
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"authType": "bearer",
|
||||
"credentials": map[string]any{"mode": "simulation"},
|
||||
"config": map[string]any{"testMode": true},
|
||||
"status": status,
|
||||
}, http.StatusCreated, &platform)
|
||||
return platform.ID
|
||||
}
|
||||
enabledPlatformID := createPlatform("layered-enabled", "enabled")
|
||||
secondPlatformID := createPlatform("layered-second", "enabled")
|
||||
disabledPlatformID := createPlatform("layered-disabled", "disabled")
|
||||
|
||||
createModel := func(platformID string, modelName string, modelTypes []string) modelAccessFixture {
|
||||
t.Helper()
|
||||
var model modelAccessFixture
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platformID+"/models", adminToken, map[string]any{
|
||||
"canonicalModelKey": "openai:" + modelName,
|
||||
"modelName": modelName,
|
||||
"modelAlias": modelName,
|
||||
"modelType": modelTypes,
|
||||
"displayName": modelName,
|
||||
}, http.StatusCreated, &model)
|
||||
return model
|
||||
}
|
||||
textName := "layered-text-" + suffix
|
||||
imageName := "layered-image-" + suffix
|
||||
multiName := "layered-multi-" + suffix
|
||||
keyDenyImageName := "layered-key-deny-image-" + suffix
|
||||
groupBImageName := "layered-group-b-image-" + suffix
|
||||
scopeTextName := "layered-scope-text-" + suffix
|
||||
legacyTextName := "layered-legacy-text-" + suffix
|
||||
textModel := createModel(enabledPlatformID, textName, []string{"text_generate"})
|
||||
imageModel := createModel(enabledPlatformID, imageName, []string{"image_generate"})
|
||||
multiModel := createModel(enabledPlatformID, multiName, []string{"text_generate", "image_generate"})
|
||||
multiSecondModel := createModel(secondPlatformID, multiName, []string{"text_generate", "image_generate"})
|
||||
disabledImageModel := createModel(disabledPlatformID, imageName, []string{"image_generate"})
|
||||
keyDenyImageModel := createModel(enabledPlatformID, keyDenyImageName, []string{"image_generate"})
|
||||
groupBImageModel := createModel(enabledPlatformID, groupBImageName, []string{"image_generate"})
|
||||
scopeTextModel := createModel(enabledPlatformID, scopeTextName, []string{"text_generate"})
|
||||
legacyTextModel := createModel(enabledPlatformID, legacyTextName, []string{"text_generate"})
|
||||
|
||||
createRule := func(subjectType string, subjectID string, resourceID string, effect string) {
|
||||
t.Helper()
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/access-rules", adminToken, map[string]any{
|
||||
"subjectType": subjectType, "subjectId": subjectID,
|
||||
"resourceType": "platform_model", "resourceId": resourceID,
|
||||
"effect": effect, "priority": 10, "status": "active",
|
||||
}, http.StatusCreated, nil)
|
||||
}
|
||||
batchKeyRule := func(ownerToken string, keyID string, resourceID string, effect string, expectedStatus int) {
|
||||
t.Helper()
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys/access-rules/batch", ownerToken, map[string]any{
|
||||
"subjectType": "api_key", "subjectId": keyID, "effect": effect,
|
||||
"upsertResources": []map[string]any{{"resourceType": "platform_model", "resourceId": resourceID, "status": "active"}},
|
||||
"deleteResources": []map[string]any{},
|
||||
}, expectedStatus, nil)
|
||||
}
|
||||
// Create valid legacy rules first, then narrow the group or scope. The rows
|
||||
// must remain for diagnostics while becoming ineffective globally.
|
||||
batchKeyRule(adminToken, keyLegacyID, legacyTextModel.ID, "allow", http.StatusOK)
|
||||
batchKeyRule(adminToken, keyScopeID, scopeTextModel.ID, "allow", http.StatusOK)
|
||||
createRule("user_group", groupAID, imageModel.ID, "allow")
|
||||
createRule("user_group", groupAID, textModel.ID, "deny")
|
||||
createRule("user_group", groupAID, legacyTextModel.ID, "deny")
|
||||
createRule("user_group", groupBID, groupBImageModel.ID, "allow")
|
||||
doJSON(t, server.URL, http.MethodPatch, "/api/v1/api-keys/"+keyScopeID+"/scopes", adminToken, map[string]any{
|
||||
"scopes": []string{"image"},
|
||||
}, http.StatusOK, nil)
|
||||
// Admin-created historical rules can refer to resources that are no longer
|
||||
// assignable. They are retained but cannot expand or reserve the resource.
|
||||
createRule("api_key", keyAID, textModel.ID, "allow")
|
||||
createRule("api_key", keyScopeID, disabledImageModel.ID, "allow")
|
||||
batchKeyRule(adminToken, keyAID, imageModel.ID, "allow", http.StatusOK)
|
||||
batchKeyRule(adminToken, keyAID, keyDenyImageModel.ID, "deny", http.StatusOK)
|
||||
|
||||
loadAssignable := func(token string, keyID string) modelAccessAssignableResponse {
|
||||
t.Helper()
|
||||
var response modelAccessAssignableResponse
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/api-keys/"+keyID+"/assignable-models", token, nil, http.StatusOK, &response)
|
||||
return response
|
||||
}
|
||||
assignable := loadAssignable(adminToken, keyAID)
|
||||
if containsModelID(assignable.Items, textModel.ID) {
|
||||
t.Fatalf("group-denied text model leaked into key candidates: %+v", assignable.Items)
|
||||
}
|
||||
if !containsModelID(assignable.Items, imageModel.ID) || !containsModelID(assignable.Items, multiModel.ID) {
|
||||
t.Fatalf("image-scope key candidates missing allowed models: %+v", assignable.Items)
|
||||
}
|
||||
if !containsModelID(assignable.Items, keyDenyImageModel.ID) {
|
||||
t.Fatalf("key rules incorrectly removed a model from the assignable set: %+v", assignable.Items)
|
||||
}
|
||||
if containsModelID(assignable.Items, disabledImageModel.ID) || containsModelID(assignable.Items, groupBImageModel.ID) {
|
||||
t.Fatalf("disabled or another-group-exclusive source leaked into assignable models: %+v", assignable.Items)
|
||||
}
|
||||
for _, model := range assignable.Items {
|
||||
if model.ID == multiModel.ID && (len(model.ModelType) != 1 || model.ModelType[0] != "image_generate") {
|
||||
t.Fatalf("multi-capability model was not scope-pruned: %+v", model)
|
||||
}
|
||||
}
|
||||
foundRevoked := false
|
||||
for _, diagnostic := range assignable.RuleDiagnostics {
|
||||
if !diagnostic.Effective && diagnostic.Reason == "owner_access_revoked" {
|
||||
foundRevoked = true
|
||||
}
|
||||
}
|
||||
if !foundRevoked {
|
||||
t.Fatalf("stale key rule diagnostic missing: %+v", assignable.RuleDiagnostics)
|
||||
}
|
||||
scopeDiagnostics := loadAssignable(adminToken, keyScopeID).RuleDiagnostics
|
||||
if !containsDiagnosticReason(scopeDiagnostics, "scope_not_allowed") || !containsDiagnosticReason(scopeDiagnostics, "resource_unavailable") {
|
||||
t.Fatalf("scope/resource diagnostics missing after narrowing: %+v", scopeDiagnostics)
|
||||
}
|
||||
legacyDiagnostics := loadAssignable(adminToken, keyLegacyID).RuleDiagnostics
|
||||
if !containsDiagnosticReason(legacyDiagnostics, "owner_access_revoked") {
|
||||
t.Fatalf("group-narrowed rule diagnostic missing: %+v", legacyDiagnostics)
|
||||
}
|
||||
t.Logf("候选与诊断:keyA=%d models, diagnostics=%d; scope=%v; legacy=%v", len(assignable.Items), len(assignable.RuleDiagnostics), diagnosticReasons(scopeDiagnostics), diagnosticReasons(legacyDiagnostics))
|
||||
|
||||
// The regular key rule endpoint must reject the same group-denied resource.
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys/access-rules/batch", adminToken, map[string]any{
|
||||
"subjectType": "api_key", "subjectId": keyAID, "effect": "allow",
|
||||
"upsertResources": []map[string]any{{"resourceType": "platform_model", "resourceId": textModel.ID, "status": "active"}},
|
||||
"deleteResources": []map[string]any{},
|
||||
}, http.StatusForbidden, nil)
|
||||
|
||||
var platformModels struct {
|
||||
Items []modelAccessFixture `json:"items"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/platform-models", keyASecret, nil, http.StatusOK, &platformModels)
|
||||
if containsModelID(platformModels.Items, textModel.ID) {
|
||||
t.Fatalf("group-denied text model leaked into key model list: %+v", platformModels.Items)
|
||||
}
|
||||
if !containsModelID(platformModels.Items, imageModel.ID) || containsModelID(platformModels.Items, keyDenyImageModel.ID) || containsModelID(platformModels.Items, disabledImageModel.ID) {
|
||||
t.Fatalf("key allow/deny or global availability was not reflected in rich list: %+v", platformModels.Items)
|
||||
}
|
||||
if !containsModelID(loadAssignable(adminToken, keyAInheritedID).Items, imageModel.ID) {
|
||||
t.Fatalf("assignable list must ignore another key's exclusive rule")
|
||||
}
|
||||
var inheritedPlatformModels struct {
|
||||
Items []modelAccessFixture `json:"items"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/platform-models", keyAInheritedSecret, nil, http.StatusOK, &inheritedPlatformModels)
|
||||
if containsModelID(inheritedPlatformModels.Items, imageModel.ID) || !containsModelID(inheritedPlatformModels.Items, keyDenyImageModel.ID) {
|
||||
t.Fatalf("key exclusive/deny isolation mismatch for sibling key: %+v", inheritedPlatformModels.Items)
|
||||
}
|
||||
groupBAssignable := loadAssignable(userBToken, keyBImageID)
|
||||
if containsModelID(groupBAssignable.Items, imageModel.ID) || !containsModelID(groupBAssignable.Items, groupBImageModel.ID) {
|
||||
t.Fatalf("group exclusive rules were not applied to key candidates: %+v", groupBAssignable.Items)
|
||||
}
|
||||
var groupBChatModels struct {
|
||||
Items []modelAccessFixture `json:"items"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/platform-models", keyBSecret, nil, http.StatusOK, &groupBChatModels)
|
||||
for _, model := range []modelAccessFixture{textModel, scopeTextModel, legacyTextModel} {
|
||||
if !containsModelID(groupBChatModels.Items, model.ID) {
|
||||
t.Fatalf("stale key/group-A rule blocked group B model %s: %+v", model.ID, groupBChatModels.Items)
|
||||
}
|
||||
}
|
||||
t.Logf("用户组与 KEY 隔离:keyA rich=%d, sibling rich=%d, groupB image candidates=%d", len(platformModels.Items), len(inheritedPlatformModels.Items), len(groupBAssignable.Items))
|
||||
|
||||
var openAIList struct {
|
||||
Object string `json:"object"`
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/v1/models", keyASecret, nil, http.StatusOK, &openAIList)
|
||||
if openAIList.Object != "list" || countOpenAIModel(openAIList.Data, multiName) != 1 {
|
||||
t.Fatalf("openai model list is not deduplicated: %+v", openAIList)
|
||||
}
|
||||
legacyHeaders := doJSONWithHeaders(t, server.URL, http.MethodGet, "/api/v1/models", keyASecret, nil, nil, http.StatusOK, &platformModels)
|
||||
if legacyHeaders.Get("Deprecation") != "true" || !strings.Contains(legacyHeaders.Get("Link"), "/api/v1/platform-models") {
|
||||
t.Fatalf("legacy model list deprecation headers missing: %+v", legacyHeaders)
|
||||
}
|
||||
|
||||
// Two sources with the same logical name collapse to one OpenAI model. A
|
||||
// source-level deny removes only that source until the final source is gone.
|
||||
var multiRich struct {
|
||||
Items []modelAccessFixture `json:"items"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/platform-models", keyMultiSecret, nil, http.StatusOK, &multiRich)
|
||||
if countRichModel(multiRich.Items, multiName) != 2 {
|
||||
t.Fatalf("expected two initial rich sources for %s: %+v", multiName, multiRich.Items)
|
||||
}
|
||||
batchKeyRule(adminToken, keyMultiID, multiModel.ID, "deny", http.StatusOK)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/v1/platform-models", keyMultiSecret, nil, http.StatusOK, &multiRich)
|
||||
if countRichModel(multiRich.Items, multiName) != 1 || containsModelID(multiRich.Items, multiModel.ID) || !containsModelID(multiRich.Items, multiSecondModel.ID) {
|
||||
t.Fatalf("first source deny did not leave exactly the second source: %+v", multiRich.Items)
|
||||
}
|
||||
var multiOpenAI struct {
|
||||
Object string `json:"object"`
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/v1/models", keyMultiSecret, nil, http.StatusOK, &multiOpenAI)
|
||||
if countOpenAIModel(multiOpenAI.Data, multiName) != 1 {
|
||||
t.Fatalf("logical model disappeared while one source remained: %+v", multiOpenAI.Data)
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyMultiSecret, map[string]any{
|
||||
"model": multiName, "prompt": "layered multi source", "runMode": "simulation", "simulation": true,
|
||||
}, http.StatusOK, nil)
|
||||
batchKeyRule(adminToken, keyMultiID, multiSecondModel.ID, "deny", http.StatusOK)
|
||||
doJSON(t, server.URL, http.MethodGet, "/v1/models", keyMultiSecret, nil, http.StatusOK, &multiOpenAI)
|
||||
if countOpenAIModel(multiOpenAI.Data, multiName) != 0 {
|
||||
t.Fatalf("logical model remained after all sources were denied: %+v", multiOpenAI.Data)
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyMultiSecret, map[string]any{
|
||||
"model": multiName, "prompt": "no sources", "runMode": "simulation", "simulation": true,
|
||||
}, http.StatusNotFound, nil)
|
||||
t.Logf("多来源模型:初始来源=2,排除一个后 OpenAI 逻辑模型=1,全部排除后=0;调用状态=200/404")
|
||||
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyASecret, map[string]any{
|
||||
"model": imageName, "prompt": "exclusive key", "runMode": "simulation", "simulation": true,
|
||||
}, http.StatusOK, nil)
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyAInheritedSecret, map[string]any{
|
||||
"model": imageName, "prompt": "sibling key", "runMode": "simulation", "simulation": true,
|
||||
}, http.StatusNotFound, nil)
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyASecret, map[string]any{
|
||||
"model": keyDenyImageName, "prompt": "key deny", "runMode": "simulation", "simulation": true,
|
||||
}, http.StatusNotFound, nil)
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyBImageSecret, map[string]any{
|
||||
"model": groupBImageName, "prompt": "group B exclusive", "runMode": "simulation", "simulation": true,
|
||||
}, http.StatusOK, nil)
|
||||
t.Logf("实际调用:KEY 专属 200,兄弟 KEY 404,KEY 排除 404,组 B 专属 200")
|
||||
|
||||
// Group B can still use text: stale allows whose owners lost group access or
|
||||
// scope are removed from the global exclusive set.
|
||||
if !containsModelID(loadAssignable(userBToken, keyBID).Items, textModel.ID) {
|
||||
t.Fatalf("group B chat key candidate list lost the group-A denied model")
|
||||
}
|
||||
for _, modelName := range []string{textName, scopeTextName, legacyTextName} {
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", keyBSecret, map[string]any{
|
||||
"model": modelName, "messages": []map[string]any{{"role": "user", "content": "layered access"}},
|
||||
"runMode": "simulation", "simulation": true,
|
||||
}, http.StatusOK, nil)
|
||||
}
|
||||
t.Logf("失效旧规则:组 B 对 3 个文本模型的 simulation 调用均为 200")
|
||||
|
||||
// The image-only key is rejected before candidate selection for chat.
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", keyASecret, map[string]any{
|
||||
"model": multiName, "messages": []map[string]any{{"role": "user", "content": "scope denial"}},
|
||||
}, http.StatusForbidden, nil)
|
||||
t.Logf("scope:image KEY 的多能力候选仅保留 image_generate;图像调用 200,文本调用 403")
|
||||
}
|
||||
|
||||
func containsModelID(items []modelAccessFixture, id string) bool {
|
||||
for _, item := range items {
|
||||
if item.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func countRichModel(items []modelAccessFixture, modelName string) int {
|
||||
count := 0
|
||||
for _, item := range items {
|
||||
if item.ModelName == modelName {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func containsDiagnosticReason(items []modelAccessRuleDiagnostic, reason string) bool {
|
||||
for _, item := range items {
|
||||
if !item.Effective && item.Reason == reason {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func diagnosticReasons(items []modelAccessRuleDiagnostic) []string {
|
||||
reasons := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !item.Effective {
|
||||
reasons = append(reasons, item.Reason)
|
||||
}
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
func countOpenAIModel(items []struct {
|
||||
ID string `json:"id"`
|
||||
}, id string) int {
|
||||
count := 0
|
||||
for _, item := range items {
|
||||
if item.ID == id {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/identity"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/modelaccess"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/netproxy"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
@@ -641,7 +642,7 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) {
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 502 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/models [get]
|
||||
// @Router /api/v1/platform-models [get]
|
||||
// @Router /api/v1/playground/models [get]
|
||||
func (s *Server) listPlayableModels(w http.ResponseWriter, r *http.Request) {
|
||||
usageScene, err := parseModelUsageSceneQuery(r.URL.Query())
|
||||
@@ -1610,41 +1611,10 @@ func writeTaskAccepted(w http.ResponseWriter, task store.GatewayTask) {
|
||||
}
|
||||
|
||||
func apiKeyScopeAllowed(user *auth.User, kind string) bool {
|
||||
if user == nil || strings.TrimSpace(user.APIKeyID) == "" || len(user.APIKeyScopes) == 0 {
|
||||
if user == nil || strings.TrimSpace(user.APIKeyID) == "" {
|
||||
return true
|
||||
}
|
||||
required := scopeForTaskKind(kind)
|
||||
for _, scope := range user.APIKeyScopes {
|
||||
scope = strings.TrimSpace(strings.ToLower(scope))
|
||||
if scope == "*" || scope == "all" || scope == required {
|
||||
return true
|
||||
}
|
||||
if required == "chat" && (scope == "text" || scope == "text_generate") {
|
||||
return true
|
||||
}
|
||||
if required == "embedding" && scope == "text_embedding" {
|
||||
return true
|
||||
}
|
||||
if required == "rerank" && scope == "text_rerank" {
|
||||
return true
|
||||
}
|
||||
if required == "music" && (scope == "audio_generate" || scope == "music_generate" || scope == "song") {
|
||||
return true
|
||||
}
|
||||
if required == "audio" && (scope == "text_to_speech" || scope == "speech" || scope == "tts") {
|
||||
return true
|
||||
}
|
||||
if required == "voice_clone" && (scope == "audio" || scope == "text_to_speech" || scope == "speech" || scope == "tts") {
|
||||
return true
|
||||
}
|
||||
if required == "image_vectorize" && (scope == "image" || scope == "vectorize") {
|
||||
return true
|
||||
}
|
||||
if required == "video_enhance" && (scope == "video" || scope == "video_upscale" || scope == "upscale") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return modelaccess.ScopeAllowsTask(user.APIKeyScopes, kind)
|
||||
}
|
||||
|
||||
func requestModelName(body map[string]any) string {
|
||||
@@ -1687,33 +1657,6 @@ func modelNameFromValue(value any) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func scopeForTaskKind(kind string) string {
|
||||
switch kind {
|
||||
case "chat.completions", "responses":
|
||||
return "chat"
|
||||
case "embeddings":
|
||||
return "embedding"
|
||||
case "reranks":
|
||||
return "rerank"
|
||||
case "images.generations", "images.edits":
|
||||
return "image"
|
||||
case "images.vectorize":
|
||||
return "image_vectorize"
|
||||
case "videos.generations":
|
||||
return "video"
|
||||
case "videos.upscales":
|
||||
return "video_enhance"
|
||||
case "song.generations", "music.generations":
|
||||
return "music"
|
||||
case "speech.generations":
|
||||
return "audio"
|
||||
case "voice.clone":
|
||||
return "voice_clone"
|
||||
default:
|
||||
return kind
|
||||
}
|
||||
}
|
||||
|
||||
func statusFromRunError(err error) int {
|
||||
switch {
|
||||
case clients.ErrorCode(err) == "binary_result_expired":
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
)
|
||||
|
||||
// listLegacyPlayableModels godoc
|
||||
// @Summary 列出可调用平台模型(已弃用)
|
||||
// @Description 兼容期 rich 平台来源明细;新客户端应改用 /api/v1/platform-models,OpenAI 客户端使用 /v1/models。
|
||||
// @Tags playground
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Deprecated
|
||||
// @Success 200 {object} PlatformModelListResponse
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/v1/models [get]
|
||||
func (s *Server) listLegacyPlayableModels(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Deprecation", "true")
|
||||
w.Header().Set("Link", "</api/v1/platform-models>; rel=\"successor-version\"")
|
||||
s.listPlayableModels(w, r)
|
||||
}
|
||||
|
||||
// listOpenAIModels godoc
|
||||
// @Summary 列出 OpenAI 兼容模型
|
||||
// @Description 按当前身份、API Key 访问规则及 scope 返回去重后的逻辑模型列表。
|
||||
// @Tags openai-compatible
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} OpenAIModelListResponse
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /v1/models [get]
|
||||
func (s *Server) listOpenAIModels(w http.ResponseWriter, r *http.Request) {
|
||||
user, _ := auth.UserFromContext(r.Context())
|
||||
models, err := s.store.ListAccessiblePlatformModels(r.Context(), user)
|
||||
if err != nil {
|
||||
s.logger.Error("list openai models failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "list models failed")
|
||||
return
|
||||
}
|
||||
byID := map[string]OpenAIModel{}
|
||||
for _, model := range models {
|
||||
id := model.ModelName
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
created := model.CreatedAt.Unix()
|
||||
current, exists := byID[id]
|
||||
if !exists || created < current.Created {
|
||||
byID[id] = OpenAIModel{ID: id, Object: "model", Created: created, OwnedBy: "easyai"}
|
||||
}
|
||||
}
|
||||
data := make([]OpenAIModel, 0, len(byID))
|
||||
for _, model := range byID {
|
||||
data = append(data, model)
|
||||
}
|
||||
sort.Slice(data, func(i, j int) bool { return data[i].ID < data[j].ID })
|
||||
writeJSON(w, http.StatusOK, OpenAIModelListResponse{Object: "list", Data: data})
|
||||
}
|
||||
@@ -107,6 +107,23 @@ type PlatformModelListResponse struct {
|
||||
Items []store.PlatformModel `json:"items"`
|
||||
}
|
||||
|
||||
type APIKeyAssignableModelsResponse struct {
|
||||
Items []store.PlatformModel `json:"items"`
|
||||
RuleDiagnostics []store.APIKeyAccessRuleDiagnostic `json:"ruleDiagnostics"`
|
||||
}
|
||||
|
||||
type OpenAIModel struct {
|
||||
ID string `json:"id" example:"gpt-4o-mini"`
|
||||
Object string `json:"object" example:"model"`
|
||||
Created int64 `json:"created" example:"1710000000"`
|
||||
OwnedBy string `json:"owned_by" example:"easyai"`
|
||||
}
|
||||
|
||||
type OpenAIModelListResponse struct {
|
||||
Object string `json:"object" example:"list"`
|
||||
Data []OpenAIModel `json:"data"`
|
||||
}
|
||||
|
||||
type CatalogProviderListResponse struct {
|
||||
Items []store.CatalogProvider `json:"items"`
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ func TestOpenAPIPublicRoutesUseCanonicalV1Prefix(t *testing.T) {
|
||||
t.Errorf("legacy public route must not be advertised in OpenAPI: %s", route)
|
||||
}
|
||||
for _, prefix := range legacyPrefixes {
|
||||
if route == "/v1/models" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(route, prefix) {
|
||||
t.Errorf("legacy public route must not be advertised in OpenAPI: %s", route)
|
||||
}
|
||||
@@ -61,6 +64,7 @@ func TestOpenAPIPublicRoutesUseCanonicalV1Prefix(t *testing.T) {
|
||||
"/api/v1/videos/generations",
|
||||
"/api/v1/videos/upscales",
|
||||
"/api/v1/pricing/estimate",
|
||||
"/v1/models",
|
||||
"/api/workspace/token-usage/daily",
|
||||
"/api/v1/models/{model}:generateContent",
|
||||
"/api/v1/videos/omni-video",
|
||||
|
||||
@@ -219,6 +219,7 @@ func NewServerWithStores(
|
||||
mux.Handle("GET /api/v1/api-keys/access-rules", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules)))
|
||||
mux.Handle("POST /api/v1/api-keys/access-rules/batch", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.batchAPIKeyAccessRules)))
|
||||
mux.Handle("GET /api/v1/api-keys/assignable-models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAssignableModels)))
|
||||
mux.Handle("GET /api/v1/api-keys/{apiKeyID}/assignable-models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAssignableModelsForKey)))
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/scopes", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.updateAPIKeyScopes)))
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey)))
|
||||
mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey)))
|
||||
@@ -296,8 +297,10 @@ func NewServerWithStores(
|
||||
mux.Handle("GET /api/admin/models", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModels)))
|
||||
mux.Handle("GET /api/v1/model-catalog", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listModelCatalog)))
|
||||
mux.Handle("GET /api/v1/platforms", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listPlayablePlatforms)))
|
||||
mux.Handle("GET /api/v1/models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
|
||||
mux.Handle("GET /api/v1/platform-models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
|
||||
mux.Handle("GET /api/v1/models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listLegacyPlayableModels)))
|
||||
mux.Handle("GET /api/v1/playground/models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listPlayableModels)))
|
||||
mux.Handle("GET /v1/models", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listOpenAIModels)))
|
||||
mux.Handle("GET /api/admin/runtime/rate-limit-windows", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listRateLimitWindows)))
|
||||
mux.Handle("GET /api/admin/runtime/model-rate-limits", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModelRateLimitStatuses)))
|
||||
mux.Handle("POST /api/v1/chat/completions", server.requireProtocolUser(clients.ProtocolOpenAIChatCompletions, server.createAPIV1ChatCompletions()))
|
||||
|
||||
Reference in New Issue
Block a user