diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 411729c..0557640 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -5726,6 +5726,7 @@ "api-keys" ], "summary": "列出 API Key 可分配模型", + "deprecated": true, "responses": { "200": { "description": "OK", @@ -5809,6 +5810,64 @@ } } }, + "/api/v1/api-keys/{apiKeyID}/assignable-models": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "返回指定 API Key 所属用户组允许、全局启用且符合 KEY scope 的平台来源,并附带已有规则有效性诊断。", + "produces": [ + "application/json" + ], + "tags": [ + "api-keys" + ], + "summary": "列出指定 API Key 可分配模型", + "parameters": [ + { + "type": "string", + "description": "API Key ID", + "name": "apiKeyID", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.APIKeyAssignableModelsResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/api/v1/api-keys/{apiKeyID}/disable": { "patch": { "security": [ @@ -7318,26 +7377,15 @@ "BearerAuth": [] } ], - "description": "按当前用户权限返回可用于 Playground 或 API 调用的模型列表。", + "description": "兼容期 rich 平台来源明细;新客户端应改用 /api/v1/platform-models,OpenAI 客户端使用 /v1/models。", "produces": [ "application/json" ], "tags": [ "playground" ], - "summary": "列出可调用模型", - "parameters": [ - { - "enum": [ - "canvas_model_node", - "desktop" - ], - "type": "string", - "description": "模型可选场景;不传时保持原有行为", - "name": "usage_scene", - "in": "query" - } - ], + "summary": "列出可调用平台模型(已弃用)", + "deprecated": true, "responses": { "200": { "description": "OK", @@ -7345,12 +7393,6 @@ "$ref": "#/definitions/httpapi.PlatformModelListResponse" } }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/httpapi.ErrorEnvelope" - } - }, "401": { "description": "Unauthorized", "schema": { @@ -7362,12 +7404,6 @@ "schema": { "$ref": "#/definitions/httpapi.ErrorEnvelope" } - }, - "502": { - "description": "Bad Gateway", - "schema": { - "$ref": "#/definitions/httpapi.ErrorEnvelope" - } } } } @@ -7682,6 +7718,67 @@ } } }, + "/api/v1/platform-models": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "按当前用户权限返回可用于 Playground 或 API 调用的模型列表。", + "produces": [ + "application/json" + ], + "tags": [ + "playground" + ], + "summary": "列出可调用模型", + "parameters": [ + { + "enum": [ + "canvas_model_node", + "desktop" + ], + "type": "string", + "description": "模型可选场景;不传时保持原有行为", + "name": "usage_scene", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.PlatformModelListResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } + }, "/api/v1/platforms": { "get": { "security": [ @@ -10240,6 +10337,43 @@ } } } + }, + "/v1/models": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "按当前身份、API Key 访问规则及 scope 返回去重后的逻辑模型列表。", + "produces": [ + "application/json" + ], + "tags": [ + "openai-compatible" + ], + "summary": "列出 OpenAI 兼容模型", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/httpapi.OpenAIModelListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpapi.ErrorEnvelope" + } + } + } + } } }, "definitions": { @@ -10314,6 +10448,23 @@ } } }, + "httpapi.APIKeyAssignableModelsResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/store.PlatformModel" + } + }, + "ruleDiagnostics": { + "type": "array", + "items": { + "$ref": "#/definitions/store.APIKeyAccessRuleDiagnostic" + } + } + } + }, "httpapi.APIKeyListResponse": { "type": "object", "properties": { @@ -11674,6 +11825,42 @@ } } }, + "httpapi.OpenAIModel": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "example": 1710000000 + }, + "id": { + "type": "string", + "example": "gpt-4o-mini" + }, + "object": { + "type": "string", + "example": "model" + }, + "owned_by": { + "type": "string", + "example": "easyai" + } + } + }, + "httpapi.OpenAIModelListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/httpapi.OpenAIModel" + } + }, + "object": { + "type": "string", + "example": "list" + } + } + }, "httpapi.PlatformListResponse": { "type": "object", "properties": { @@ -13123,6 +13310,37 @@ } } }, + "store.APIKeyAccessRuleDiagnostic": { + "type": "object", + "properties": { + "effect": { + "type": "string" + }, + "effective": { + "type": "boolean" + }, + "reason": { + "type": "string", + "enum": [ + "resource_unavailable", + "owner_access_revoked", + "scope_not_allowed" + ] + }, + "resourceId": { + "type": "string" + }, + "resourceName": { + "type": "string" + }, + "resourceType": { + "type": "string" + }, + "ruleId": { + "type": "string" + } + } + }, "store.AcceptanceRun": { "type": "object", "properties": { diff --git a/apps/api/docs/swagger.yaml b/apps/api/docs/swagger.yaml index 826f817..71c9ddd 100644 --- a/apps/api/docs/swagger.yaml +++ b/apps/api/docs/swagger.yaml @@ -47,6 +47,17 @@ definitions: username: type: string type: object + httpapi.APIKeyAssignableModelsResponse: + properties: + items: + items: + $ref: '#/definitions/store.PlatformModel' + type: array + ruleDiagnostics: + items: + $ref: '#/definitions/store.APIKeyAccessRuleDiagnostic' + type: array + type: object httpapi.APIKeyListResponse: properties: items: @@ -989,6 +1000,31 @@ definitions: $ref: '#/definitions/httpapi.OpenAIImageData' type: array type: object + httpapi.OpenAIModel: + properties: + created: + example: 1710000000 + type: integer + id: + example: gpt-4o-mini + type: string + object: + example: model + type: string + owned_by: + example: easyai + type: string + type: object + httpapi.OpenAIModelListResponse: + properties: + data: + items: + $ref: '#/definitions/httpapi.OpenAIModel' + type: array + object: + example: list + type: string + type: object httpapi.PlatformListResponse: properties: items: @@ -1999,6 +2035,27 @@ definitions: userId: type: string type: object + store.APIKeyAccessRuleDiagnostic: + properties: + effect: + type: string + effective: + type: boolean + reason: + enum: + - resource_unavailable + - owner_access_revoked + - scope_not_allowed + type: string + resourceId: + type: string + resourceName: + type: string + resourceType: + type: string + ruleId: + type: string + type: object store.AcceptanceRun: properties: apiImageDigest: @@ -7879,6 +7936,43 @@ paths: summary: 删除 API Key tags: - api-keys + /api/v1/api-keys/{apiKeyID}/assignable-models: + get: + description: 返回指定 API Key 所属用户组允许、全局启用且符合 KEY scope 的平台来源,并附带已有规则有效性诊断。 + parameters: + - description: API Key ID + in: path + name: apiKeyID + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.APIKeyAssignableModelsResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "404": + description: Not Found + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 列出指定 API Key 可分配模型 + tags: + - api-keys /api/v1/api-keys/{apiKeyID}/disable: patch: description: 禁用当前用户拥有的 API Key,保留记录但不再允许调用。 @@ -8038,6 +8132,7 @@ paths: - api-keys /api/v1/api-keys/assignable-models: get: + deprecated: true description: 按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。 produces: - application/json @@ -8954,15 +9049,8 @@ paths: - model-catalog /api/v1/models: get: - description: 按当前用户权限返回可用于 Playground 或 API 调用的模型列表。 - parameters: - - description: 模型可选场景;不传时保持原有行为 - enum: - - canvas_model_node - - desktop - in: query - name: usage_scene - type: string + deprecated: true + description: 兼容期 rich 平台来源明细;新客户端应改用 /api/v1/platform-models,OpenAI 客户端使用 /v1/models。 produces: - application/json responses: @@ -8970,10 +9058,6 @@ paths: description: OK schema: $ref: '#/definitions/httpapi.PlatformModelListResponse' - "400": - description: Bad Request - schema: - $ref: '#/definitions/httpapi.ErrorEnvelope' "401": description: Unauthorized schema: @@ -8982,13 +9066,9 @@ paths: description: Internal Server Error schema: $ref: '#/definitions/httpapi.ErrorEnvelope' - "502": - description: Bad Gateway - schema: - $ref: '#/definitions/httpapi.ErrorEnvelope' security: - BearerAuth: [] - summary: 列出可调用模型 + summary: 列出可调用平台模型(已弃用) tags: - playground /api/v1/models/{model}:generateContent: @@ -9195,6 +9275,45 @@ paths: summary: 获取 AI Gateway Swagger YAML tags: - agent-resources + /api/v1/platform-models: + get: + description: 按当前用户权限返回可用于 Playground 或 API 调用的模型列表。 + parameters: + - description: 模型可选场景;不传时保持原有行为 + enum: + - canvas_model_node + - desktop + in: query + name: usage_scene + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.PlatformModelListResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "502": + description: Bad Gateway + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 列出可调用模型 + tags: + - playground /api/v1/platforms: get: description: 按当前用户可访问模型过滤平台,仅返回启用且存在可访问模型的平台。 @@ -10853,6 +10972,29 @@ paths: summary: 获取本地上传资源 tags: - static + /v1/models: + get: + description: 按当前身份、API Key 访问规则及 scope 返回去重后的逻辑模型列表。 + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/httpapi.OpenAIModelListResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpapi.ErrorEnvelope' + security: + - BearerAuth: [] + summary: 列出 OpenAI 兼容模型 + tags: + - openai-compatible schemes: - http - https diff --git a/apps/api/internal/httpapi/access_rule_handlers.go b/apps/api/internal/httpapi/access_rule_handlers.go index 7cd20ef..e77e9ab 100644 --- a/apps/api/internal/httpapi/access_rule_handlers.go +++ b/apps/api/internal/httpapi/access_rule_handlers.go @@ -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) diff --git a/apps/api/internal/httpapi/api_key_model_access_integration_test.go b/apps/api/internal/httpapi/api_key_model_access_integration_test.go new file mode 100644 index 0000000..a57f5b9 --- /dev/null +++ b/apps/api/internal/httpapi/api_key_model_access_integration_test.go @@ -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 +} diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index 1f9373d..85e206c 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -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": diff --git a/apps/api/internal/httpapi/model_list_handlers.go b/apps/api/internal/httpapi/model_list_handlers.go new file mode 100644 index 0000000..49d70b4 --- /dev/null +++ b/apps/api/internal/httpapi/model_list_handlers.go @@ -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", "; 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}) +} diff --git a/apps/api/internal/httpapi/openapi_models.go b/apps/api/internal/httpapi/openapi_models.go index aa70b9b..6ec469b 100644 --- a/apps/api/internal/httpapi/openapi_models.go +++ b/apps/api/internal/httpapi/openapi_models.go @@ -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"` } diff --git a/apps/api/internal/httpapi/public_api_prefix_test.go b/apps/api/internal/httpapi/public_api_prefix_test.go index d054f88..31acbbe 100644 --- a/apps/api/internal/httpapi/public_api_prefix_test.go +++ b/apps/api/internal/httpapi/public_api_prefix_test.go @@ -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", diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 6419ea4..80bc76e 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -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())) diff --git a/apps/api/internal/modelaccess/scopes.go b/apps/api/internal/modelaccess/scopes.go new file mode 100644 index 0000000..4cf8ff0 --- /dev/null +++ b/apps/api/internal/modelaccess/scopes.go @@ -0,0 +1,166 @@ +package modelaccess + +import "strings" + +// ScopeAllowsTask reports whether scopes authorize one public task kind. +// Empty scopes retain the legacy unrestricted behavior for old API keys. +func ScopeAllowsTask(scopes []string, kind string) bool { + if len(scopes) == 0 { + return true + } + return scopeAllowsCapability(scopes, capabilityForTaskKind(kind)) +} + +// ScopeAllowsModelType reports whether scopes authorize one runtime model type. +// Unknown model types are deny-by-default unless the key has all or an exact +// custom scope matching that model type. +func ScopeAllowsModelType(scopes []string, modelType string) bool { + if len(scopes) == 0 { + return true + } + modelType = normalize(modelType) + if modelType == "" { + return false + } + capability := capabilityForModelType(modelType) + if capability == "" { + return hasScope(scopes, modelType) + } + return scopeAllowsCapability(scopes, capability) +} + +// FilterModelTypes keeps the declared order while removing model types that +// the key cannot invoke. +func FilterModelTypes(scopes []string, modelTypes []string) []string { + if len(modelTypes) == 0 { + return nil + } + filtered := make([]string, 0, len(modelTypes)) + seen := map[string]bool{} + for _, modelType := range modelTypes { + normalized := normalize(modelType) + if normalized == "" || seen[normalized] || !ScopeAllowsModelType(scopes, normalized) { + continue + } + seen[normalized] = true + filtered = append(filtered, normalized) + } + return filtered +} + +func capabilityForTaskKind(kind string) string { + switch normalize(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 normalize(kind) + } +} + +func capabilityForModelType(modelType string) string { + switch normalize(modelType) { + case "text_generate", "tools_call": + return "chat" + case "text_embedding": + return "embedding" + case "text_rerank": + return "rerank" + case "image_generate", "image_edit", "image_analysis": + return "image" + case "image_vectorize": + return "image_vectorize" + case "video_generate", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "video_understanding", "omni_video", "omni": + return "video" + case "video_enhance": + return "video_enhance" + case "audio_generate", "music_generate": + return "music" + case "text_to_speech", "audio_understanding": + return "audio" + case "voice_clone": + return "voice_clone" + default: + return "" + } +} + +func scopeAllowsCapability(scopes []string, capability string) bool { + capability = normalize(capability) + if capability == "" { + return false + } + for _, scope := range scopes { + scope = normalize(scope) + if scope == "*" || scope == "all" || scope == capability { + return true + } + switch capability { + case "chat": + if scope == "text" || scope == "text_generate" { + return true + } + case "embedding": + if scope == "text_embedding" { + return true + } + case "rerank": + if scope == "text_rerank" { + return true + } + case "music": + if scope == "audio_generate" || scope == "music_generate" || scope == "song" { + return true + } + case "audio": + if scope == "text_to_speech" || scope == "speech" || scope == "tts" { + return true + } + case "voice_clone": + if scope == "audio" || scope == "text_to_speech" || scope == "speech" || scope == "tts" { + return true + } + case "image_vectorize": + if scope == "image" || scope == "vectorize" { + return true + } + case "video_enhance": + if scope == "video" || scope == "video_upscale" || scope == "upscale" { + return true + } + } + } + return false +} + +func hasScope(scopes []string, want string) bool { + want = normalize(want) + for _, scope := range scopes { + scope = normalize(scope) + if scope == "*" || scope == "all" || scope == want { + return true + } + } + return false +} + +func normalize(value string) string { + return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(value)), "-", "_") +} diff --git a/apps/api/internal/modelaccess/scopes_test.go b/apps/api/internal/modelaccess/scopes_test.go new file mode 100644 index 0000000..2c1ff1b --- /dev/null +++ b/apps/api/internal/modelaccess/scopes_test.go @@ -0,0 +1,122 @@ +package modelaccess + +import ( + "reflect" + "testing" +) + +func TestScopeAllowsTaskPreservesAliases(t *testing.T) { + tests := []struct { + scopes []string + kind string + allowed bool + }{ + {[]string{"chat"}, "responses", true}, + {[]string{"text_generate"}, "chat.completions", true}, + {[]string{"image"}, "images.vectorize", true}, + {[]string{"video"}, "videos.upscales", true}, + {[]string{"audio_generate"}, "music.generations", true}, + {[]string{"text_to_speech"}, "speech.generations", true}, + {[]string{"image"}, "chat.completions", false}, + {nil, "chat.completions", true}, + } + for _, test := range tests { + if got := ScopeAllowsTask(test.scopes, test.kind); got != test.allowed { + t.Fatalf("ScopeAllowsTask(%v, %q) = %v, want %v", test.scopes, test.kind, got, test.allowed) + } + } +} + +func TestFilterModelTypesUsesCapabilityScopes(t *testing.T) { + tests := []struct { + name string + scopes []string + types []string + want []string + }{ + {"image", []string{"image"}, []string{"text_generate", "image_generate", "image_edit"}, []string{"image_generate", "image_edit"}}, + {"video alias", []string{"video"}, []string{"image_to_video", "video_enhance", "text_generate"}, []string{"image_to_video", "video_enhance"}}, + {"custom exact", []string{"custom_type"}, []string{"custom_type", "other_type"}, []string{"custom_type"}}, + {"all", []string{"all"}, []string{"text_generate", "unknown"}, []string{"text_generate", "unknown"}}, + {"legacy empty", nil, []string{"text_generate", "unknown"}, []string{"text_generate", "unknown"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := FilterModelTypes(test.scopes, test.types); !reflect.DeepEqual(got, test.want) { + t.Fatalf("FilterModelTypes(%v, %v) = %v, want %v", test.scopes, test.types, got, test.want) + } + }) + } +} + +func TestKnownTaskKindsUseExpectedScopes(t *testing.T) { + tests := []struct { + kind string + scope string + }{ + {"chat.completions", "chat"}, + {"responses", "text_generate"}, + {"embeddings", "text_embedding"}, + {"reranks", "text_rerank"}, + {"images.generations", "image"}, + {"images.edits", "image"}, + {"images.vectorize", "vectorize"}, + {"videos.generations", "video"}, + {"videos.upscales", "video_upscale"}, + {"song.generations", "song"}, + {"music.generations", "music_generate"}, + {"speech.generations", "tts"}, + {"voice.clone", "audio"}, + } + for _, test := range tests { + t.Run(test.kind, func(t *testing.T) { + if !ScopeAllowsTask([]string{test.scope}, test.kind) { + t.Fatalf("scope %q should allow task %q", test.scope, test.kind) + } + if ScopeAllowsTask([]string{"unrelated"}, test.kind) { + t.Fatalf("unrelated scope allowed task %q", test.kind) + } + }) + } +} + +func TestKnownModelTypesUseExpectedScopes(t *testing.T) { + tests := []struct { + modelType string + scope string + }{ + {"text_generate", "chat"}, + {"tools_call", "text"}, + {"text_embedding", "embedding"}, + {"text_rerank", "rerank"}, + {"image_generate", "image"}, + {"image_edit", "image"}, + {"image_analysis", "image"}, + {"image_vectorize", "vectorize"}, + {"video_generate", "video"}, + {"image_to_video", "video"}, + {"text_to_video", "video"}, + {"video_edit", "video"}, + {"video_reference", "video"}, + {"video_first_last_frame", "video"}, + {"video_understanding", "video"}, + {"omni_video", "video"}, + {"omni", "video"}, + {"video_enhance", "upscale"}, + {"audio_generate", "music"}, + {"music_generate", "song"}, + {"text_to_speech", "speech"}, + {"audio_understanding", "audio"}, + {"voice_clone", "tts"}, + } + for _, test := range tests { + t.Run(test.modelType, func(t *testing.T) { + if !ScopeAllowsModelType([]string{test.scope}, test.modelType) { + t.Fatalf("scope %q should allow model type %q", test.scope, test.modelType) + } + if ScopeAllowsModelType([]string{"unrelated"}, test.modelType) { + t.Fatalf("unrelated scope allowed model type %q", test.modelType) + } + }) + } +} diff --git a/apps/api/internal/store/access_policy.go b/apps/api/internal/store/access_policy.go new file mode 100644 index 0000000..8459355 --- /dev/null +++ b/apps/api/internal/store/access_policy.go @@ -0,0 +1,507 @@ +package store + +import ( + "context" + "strings" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/modelaccess" +) + +type APIKeyAccessRuleDiagnostic struct { + RuleID string `json:"ruleId"` + ResourceType string `json:"resourceType"` + ResourceID string `json:"resourceId"` + ResourceName string `json:"resourceName,omitempty"` + Effect string `json:"effect"` + Effective bool `json:"effective"` + Reason string `json:"reason,omitempty" enums:"resource_unavailable,owner_access_revoked,scope_not_allowed"` +} + +func (s *Store) enabledPlatformModels(ctx context.Context) ([]PlatformModel, []Platform, error) { + models, err := s.ListModels(ctx) + if err != nil { + return nil, nil, err + } + platforms, err := s.ListPlatforms(ctx) + if err != nil { + return nil, nil, err + } + enabledPlatforms := map[string]bool{} + for _, platform := range platforms { + if platform.Status == "enabled" { + enabledPlatforms[platform.ID] = true + } + } + enabled := make([]PlatformModel, 0, len(models)) + for _, model := range models { + if model.Enabled && enabledPlatforms[model.PlatformID] { + enabled = append(enabled, model) + } + } + return enabled, platforms, nil +} + +func (s *Store) filterPlatformModelsByLayeredAccess(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) { + rules, err := s.listActiveAccessRulesForResources(ctx, platformModelAccessResources(models)) + if err != nil { + return nil, err + } + baselineRules, apiKeyRules := splitLayeredAccessRules(rules) + baseline := filterPlatformModelsByRuleSet(models, baselineRules, baselineAccessRuleSubjects(user), permissionLevel(user)) + if user == nil || strings.TrimSpace(user.APIKeyID) == "" { + return baseline, nil + } + apiKeyUsers, err := s.apiKeyAccessRuleUsers(ctx, apiKeyRules) + if err != nil { + return nil, err + } + return filterPlatformModelsByLayeredRuleSet(user, baseline, baselineRules, apiKeyRules, apiKeyUsers), nil +} + +func filterPlatformModelsByLayeredRuleSet(user *auth.User, baseline []PlatformModel, baselineRules []AccessRule, apiKeyRules []AccessRule, apiKeyUsers map[string]*auth.User) []PlatformModel { + if user == nil || strings.TrimSpace(user.APIKeyID) == "" { + return baseline + } + keyFiltered := make([]PlatformModel, 0, len(baseline)) + for _, model := range baseline { + effectiveRules := effectiveAPIKeyRulesForPlatformModel(apiKeyRules, baselineRules, apiKeyUsers, model) + if platformModelAllowedByAccessRules(model, effectiveRules, apiKeyAccessRuleSubjects(user), permissionLevel(user)) { + keyFiltered = append(keyFiltered, model) + } + } + return filterPlatformModelsByAPIKeyScopes(keyFiltered, user.APIKeyScopes) +} + +func (s *Store) filterPlatformModelsByBaselineAccess(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) { + rules, err := s.listActiveAccessRulesForResources(ctx, platformModelAccessResources(models)) + if err != nil { + return nil, err + } + baselineRules, _ := splitLayeredAccessRules(rules) + return filterPlatformModelsByRuleSet(models, baselineRules, baselineAccessRuleSubjects(user), permissionLevel(user)), nil +} + +func (s *Store) filterRuntimeCandidatesByLayeredAccess(ctx context.Context, user *auth.User, candidates []RuntimeModelCandidate) ([]RuntimeModelCandidate, error) { + if len(candidates) == 0 { + return candidates, nil + } + accessUser, err := s.resolveCurrentAccessUser(ctx, user) + if err != nil { + return nil, err + } + rules, err := s.listActiveAccessRulesForResources(ctx, candidateAccessResources(candidates)) + if err != nil { + return nil, err + } + baselineRules, apiKeyRules := splitLayeredAccessRules(rules) + baseline := filterCandidatesByRuleSet(candidates, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser)) + if accessUser == nil || strings.TrimSpace(accessUser.APIKeyID) == "" { + return baseline, nil + } + apiKeyUsers, err := s.apiKeyAccessRuleUsers(ctx, apiKeyRules) + if err != nil { + return nil, err + } + keyFiltered := make([]RuntimeModelCandidate, 0, len(baseline)) + for _, candidate := range baseline { + effectiveRules := effectiveAPIKeyRulesForCandidate(apiKeyRules, baselineRules, apiKeyUsers, candidate) + if candidateAllowedByAccessRules(candidate, effectiveRules, apiKeyAccessRuleSubjects(accessUser), permissionLevel(accessUser)) { + keyFiltered = append(keyFiltered, candidate) + } + } + filtered := make([]RuntimeModelCandidate, 0, len(keyFiltered)) + for _, candidate := range keyFiltered { + if modelaccess.ScopeAllowsModelType(accessUser.APIKeyScopes, candidate.ModelType) { + filtered = append(filtered, candidate) + } + } + return filtered, nil +} + +func (s *Store) ListAPIKeyAssignablePlatformModelsForKey(ctx context.Context, user *auth.User, apiKeyID string) ([]PlatformModel, []APIKeyAccessRuleDiagnostic, error) { + if localGatewayUserID(user) == "" { + return nil, nil, ErrLocalUserRequired + } + accessUser, err := s.resolveOwnedAPIKeyAccessUser(ctx, user, apiKeyID) + if err != nil { + return nil, nil, err + } + allModels, err := s.ListModels(ctx) + if err != nil { + return nil, nil, err + } + enabledModels, platforms, err := s.enabledPlatformModels(ctx) + if err != nil { + return nil, nil, err + } + rules, err := s.listActiveAccessRulesForResources(ctx, platformModelAccessResources(enabledModels)) + if err != nil { + return nil, nil, err + } + baselineRules, _ := splitLayeredAccessRules(rules) + baseline := filterPlatformModelsByRuleSet(enabledModels, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser)) + scoped := filterPlatformModelsByAPIKeyScopes(baseline, accessUser.APIKeyScopes) + ownedRules, err := s.ListAPIKeyAccessRules(ctx, user) + if err != nil { + return nil, nil, err + } + diagnostics := diagnoseAPIKeyRules(apiKeyID, ownedRules, allModels, enabledModels, baseline, scoped, platforms) + return scoped, diagnostics, nil +} + +func (s *Store) resolveOwnedAPIKeyAccessUser(ctx context.Context, user *auth.User, apiKeyID string) (*auth.User, error) { + if user == nil { + return nil, ErrLocalUserRequired + } + gatewayUserID := localGatewayUserID(user) + if gatewayUserID == "" { + return nil, ErrLocalUserRequired + } + var scopesBytes []byte + var userGroupID string + var userGroupKey string + err := s.pool.QueryRow(ctx, ` +SELECT k.scopes, COALESCE(k.user_group_id::text, u.default_user_group_id::text, ''), COALESCE(g.group_key, '') +FROM gateway_api_keys k +JOIN gateway_users u ON u.id = k.gateway_user_id +LEFT JOIN gateway_user_groups g ON g.id = COALESCE(k.user_group_id, u.default_user_group_id) +WHERE k.id = $1::uuid + AND k.gateway_user_id = $2::uuid + AND k.deleted_at IS NULL + AND u.deleted_at IS NULL`, strings.TrimSpace(apiKeyID), gatewayUserID).Scan(&scopesBytes, &userGroupID, &userGroupKey) + if err != nil { + return nil, err + } + next := *user + next.APIKeyID = strings.TrimSpace(apiKeyID) + next.APIKeyScopes = decodeStringArray(scopesBytes) + next.UserGroupID = userGroupID + next.UserGroupKey = userGroupKey + next.UserGroupKeys = nil + if userGroupKey != "" { + next.UserGroupKeys = []string{userGroupKey} + } + return &next, nil +} + +func effectiveAPIKeyRulesForPlatformModel(rules []AccessRule, baselineRules []AccessRule, users map[string]*auth.User, model PlatformModel) []AccessRule { + effective := make([]AccessRule, 0, len(rules)) + for _, rule := range rules { + accessUser := users[rule.SubjectID] + if accessUser == nil || !accessRuleMatchesPlatformModel(rule, model) || + !platformModelAllowedByAccessRules(model, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser)) || + len(modelaccess.FilterModelTypes(accessUser.APIKeyScopes, model.ModelType)) == 0 { + continue + } + effective = append(effective, rule) + } + return effective +} + +func effectiveAPIKeyRulesForCandidate(rules []AccessRule, baselineRules []AccessRule, users map[string]*auth.User, candidate RuntimeModelCandidate) []AccessRule { + effective := make([]AccessRule, 0, len(rules)) + for _, rule := range rules { + accessUser := users[rule.SubjectID] + if accessUser == nil || !accessRuleMatchesCandidate(rule, candidate) || + !candidateAllowedByAccessRules(candidate, baselineRules, baselineAccessRuleSubjects(accessUser), permissionLevel(accessUser)) || + !modelaccess.ScopeAllowsModelType(accessUser.APIKeyScopes, candidate.ModelType) { + continue + } + effective = append(effective, rule) + } + return effective +} + +func (s *Store) apiKeyAccessRuleUsers(ctx context.Context, rules []AccessRule) (map[string]*auth.User, error) { + ids := make([]string, 0, len(rules)) + seen := map[string]bool{} + for _, rule := range rules { + if rule.SubjectType != "api_key" || rule.SubjectID == "" || seen[rule.SubjectID] { + continue + } + seen[rule.SubjectID] = true + ids = append(ids, rule.SubjectID) + } + if len(ids) == 0 { + return map[string]*auth.User{}, nil + } + rows, err := s.pool.Query(ctx, ` +SELECT k.id::text, k.scopes, u.id::text, + COALESCE(u.gateway_tenant_id::text, ''), COALESCE(u.tenant_id, ''), COALESCE(u.tenant_key, ''), + u.roles, COALESCE(k.user_group_id::text, u.default_user_group_id::text, ''), COALESCE(g.group_key, '') +FROM gateway_api_keys k +JOIN gateway_users u ON u.id = k.gateway_user_id +LEFT JOIN gateway_user_groups g ON g.id = COALESCE(k.user_group_id, u.default_user_group_id) +WHERE k.id = ANY($1::uuid[]) + AND k.status = 'active' + AND k.deleted_at IS NULL + AND (k.expires_at IS NULL OR k.expires_at > now()) + AND u.status = 'active' + AND u.deleted_at IS NULL`, ids) + if err != nil { + return nil, err + } + defer rows.Close() + users := map[string]*auth.User{} + for rows.Next() { + var apiKeyID string + var scopesBytes []byte + var rolesBytes []byte + var gatewayUserID string + var gatewayTenantID string + var tenantID string + var tenantKey string + var userGroupID string + var userGroupKey string + if err := rows.Scan(&apiKeyID, &scopesBytes, &gatewayUserID, &gatewayTenantID, &tenantID, &tenantKey, &rolesBytes, &userGroupID, &userGroupKey); err != nil { + return nil, err + } + groupKeys := []string(nil) + if userGroupKey != "" { + groupKeys = []string{userGroupKey} + } + users[apiKeyID] = &auth.User{ + GatewayUserID: gatewayUserID, + GatewayTenantID: gatewayTenantID, + TenantID: tenantID, + TenantKey: tenantKey, + Roles: decodeStringArray(rolesBytes), + UserGroupID: userGroupID, + UserGroupKey: userGroupKey, + UserGroupKeys: groupKeys, + APIKeyID: apiKeyID, + APIKeyScopes: decodeStringArray(scopesBytes), + } + } + return users, rows.Err() +} + +func splitLayeredAccessRules(rules []AccessRule) ([]AccessRule, []AccessRule) { + baseline := make([]AccessRule, 0, len(rules)) + apiKeys := make([]AccessRule, 0, len(rules)) + for _, rule := range rules { + if rule.SubjectType == "api_key" { + apiKeys = append(apiKeys, rule) + } else { + baseline = append(baseline, rule) + } + } + return baseline, apiKeys +} + +func filterPlatformModelsByRuleSet(models []PlatformModel, rules []AccessRule, subjects map[string]bool, level int) []PlatformModel { + filtered := make([]PlatformModel, 0, len(models)) + for _, model := range models { + if platformModelAllowedByAccessRules(model, rules, subjects, level) { + filtered = append(filtered, model) + } + } + return filtered +} + +func filterCandidatesByRuleSet(candidates []RuntimeModelCandidate, rules []AccessRule, subjects map[string]bool, level int) []RuntimeModelCandidate { + filtered := make([]RuntimeModelCandidate, 0, len(candidates)) + for _, candidate := range candidates { + if candidateAllowedByAccessRules(candidate, rules, subjects, level) { + filtered = append(filtered, candidate) + } + } + return filtered +} + +func filterPlatformModelsByAPIKeyScopes(models []PlatformModel, scopes []string) []PlatformModel { + filtered := make([]PlatformModel, 0, len(models)) + for _, model := range models { + declaredTypes := append(StringList(nil), model.ModelType...) + allowedTypes := modelaccess.FilterModelTypes(scopes, model.ModelType) + if len(allowedTypes) == 0 { + continue + } + model.ModelType = StringList(allowedTypes) + model.Capabilities = filterPlatformModelTypeConfig(model.Capabilities, allowedTypes, declaredTypes) + model.BaseCapabilities = filterPlatformModelTypeConfig(model.BaseCapabilities, allowedTypes, declaredTypes) + model.CapabilityOverride = filterPlatformModelTypeConfig(model.CapabilityOverride, allowedTypes, declaredTypes) + filtered = append(filtered, model) + } + return filtered +} + +func filterPlatformModelTypeConfig(config map[string]any, allowedTypes []string, declaredTypes []string) map[string]any { + if len(config) == 0 { + return config + } + allowed := map[string]bool{} + for _, modelType := range allowedTypes { + allowed[modelType] = true + } + declared := map[string]bool{} + for _, modelType := range declaredTypes { + declared[modelType] = true + } + out := make(map[string]any, len(config)) + for key, value := range config { + if key == "originalTypes" { + original := stringValues(value) + kept := make([]string, 0, len(original)) + for _, modelType := range original { + if allowed[modelType] { + kept = append(kept, modelType) + } + } + if len(kept) > 0 { + out[key] = kept + } + continue + } + if (declared[key] || knownPlatformModelType(key)) && !allowed[key] { + continue + } + out[key] = value + } + return out +} + +func knownPlatformModelType(value string) bool { + switch value { + case "text_generate", "text_embedding", "text_rerank", "tools_call", + "image_generate", "image_edit", "image_analysis", "image_vectorize", + "video_generate", "video_enhance", "image_to_video", "text_to_video", "video_edit", "video_reference", "video_first_last_frame", "video_understanding", "omni_video", "omni", + "audio_generate", "music_generate", "audio_understanding", "text_to_speech", "voice_clone": + return true + default: + return false + } +} + +func stringValues(value any) []string { + switch values := value.(type) { + case []string: + return values + case StringList: + return []string(values) + case []any: + out := make([]string, 0, len(values)) + for _, value := range values { + if text, ok := value.(string); ok && text != "" { + out = append(out, text) + } + } + return out + default: + return nil + } +} + +func baselineAccessRuleSubjects(user *auth.User) map[string]bool { + subjects := accessRuleSubjects(user) + if user != nil && user.APIKeyID != "" { + delete(subjects, "api_key:"+user.APIKeyID) + } + return subjects +} + +func apiKeyAccessRuleSubjects(user *auth.User) map[string]bool { + subjects := map[string]bool{} + if user != nil && strings.TrimSpace(user.APIKeyID) != "" { + subjects["api_key:"+strings.TrimSpace(user.APIKeyID)] = true + } + return subjects +} + +func permissionLevel(user *auth.User) int { + if user == nil { + return 0 + } + return auth.PermissionLevel(user.Roles) +} + +func accessRuleMatchesPlatformModel(rule AccessRule, model PlatformModel) bool { + switch rule.ResourceType { + case "platform": + return rule.ResourceID == model.PlatformID + case "platform_model": + return rule.ResourceID == model.ID + case "base_model": + return rule.ResourceID != "" && rule.ResourceID == model.BaseModelID + default: + return false + } +} + +func accessRuleMatchesCandidate(rule AccessRule, candidate RuntimeModelCandidate) bool { + switch rule.ResourceType { + case "platform": + return rule.ResourceID == candidate.PlatformID + case "platform_model": + return rule.ResourceID == candidate.PlatformModelID + case "base_model": + return rule.ResourceID != "" && rule.ResourceID == candidate.BaseModelID + default: + return false + } +} + +func diagnoseAPIKeyRules(apiKeyID string, rules []AccessRule, allModels []PlatformModel, enabledModels []PlatformModel, baselineModels []PlatformModel, scopedModels []PlatformModel, platforms []Platform) []APIKeyAccessRuleDiagnostic { + platformNames := map[string]string{} + for _, platform := range platforms { + platformNames[platform.ID] = firstNonEmpty(platform.InternalName, platform.Name, platform.PlatformKey) + } + modelNames := map[string]string{} + baseModelNames := map[string]string{} + for _, model := range allModels { + modelNames[model.ID] = firstNonEmpty(model.DisplayName, model.ModelName, model.ID) + if model.BaseModelID != "" && baseModelNames[model.BaseModelID] == "" { + baseModelNames[model.BaseModelID] = firstNonEmpty(model.DisplayName, model.ModelName, model.BaseModelID) + } + } + diagnostics := make([]APIKeyAccessRuleDiagnostic, 0) + for _, rule := range rules { + if rule.SubjectType != "api_key" || rule.SubjectID != apiKeyID || rule.Status != "active" { + continue + } + diagnostic := APIKeyAccessRuleDiagnostic{ + RuleID: rule.ID, + ResourceType: rule.ResourceType, + ResourceID: rule.ResourceID, + ResourceName: accessRuleResourceName(rule, platformNames, modelNames, baseModelNames), + Effect: rule.Effect, + Effective: true, + } + switch { + case !accessRuleMatchesAnyPlatformModel(rule, allModels) || !accessRuleMatchesAnyPlatformModel(rule, enabledModels): + diagnostic.Effective = false + diagnostic.Reason = "resource_unavailable" + case !accessRuleMatchesAnyPlatformModel(rule, baselineModels): + diagnostic.Effective = false + diagnostic.Reason = "owner_access_revoked" + case !accessRuleMatchesAnyPlatformModel(rule, scopedModels): + diagnostic.Effective = false + diagnostic.Reason = "scope_not_allowed" + } + diagnostics = append(diagnostics, diagnostic) + } + return diagnostics +} + +func accessRuleMatchesAnyPlatformModel(rule AccessRule, models []PlatformModel) bool { + for _, model := range models { + if accessRuleMatchesPlatformModel(rule, model) { + return true + } + } + return false +} + +func accessRuleResourceName(rule AccessRule, platformNames map[string]string, modelNames map[string]string, baseModelNames map[string]string) string { + switch rule.ResourceType { + case "platform": + return firstNonEmpty(platformNames[rule.ResourceID], rule.ResourceID) + case "platform_model": + return firstNonEmpty(modelNames[rule.ResourceID], rule.ResourceID) + case "base_model": + return firstNonEmpty(baseModelNames[rule.ResourceID], rule.ResourceID) + default: + return rule.ResourceID + } +} diff --git a/apps/api/internal/store/access_policy_test.go b/apps/api/internal/store/access_policy_test.go new file mode 100644 index 0000000..889242d --- /dev/null +++ b/apps/api/internal/store/access_policy_test.go @@ -0,0 +1,150 @@ +package store + +import ( + "reflect" + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" +) + +func TestLayeredAccessDoesNotLetAPIKeyExpandBaseline(t *testing.T) { + model := PlatformModel{ID: "model-1", PlatformID: "platform-1", BaseModelID: "base-1"} + groupUser := &auth.User{GatewayUserID: "user-1", UserGroupID: "group-1", APIKeyID: "key-1"} + baselineRules := []AccessRule{{ + SubjectType: "user_group", SubjectID: "group-1", ResourceType: "platform_model", ResourceID: "model-1", Effect: "deny", + }} + keyRules := []AccessRule{{ + SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow", + }} + baseline := filterPlatformModelsByRuleSet([]PlatformModel{model}, baselineRules, baselineAccessRuleSubjects(groupUser), 0) + actual := filterPlatformModelsByRuleSet(baseline, keyRules, apiKeyAccessRuleSubjects(groupUser), 0) + if len(actual) != 0 { + t.Fatalf("api key allow expanded denied baseline: %+v", actual) + } +} + +func TestAPIKeyAllowControlsOnlyMatchingKeys(t *testing.T) { + model := PlatformModel{ID: "model-1", PlatformID: "platform-1"} + rules := []AccessRule{{ + SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow", + }} + for _, test := range []struct { + keyID string + want int + }{{"key-a", 1}, {"key-b", 0}} { + user := &auth.User{APIKeyID: test.keyID} + actual := filterPlatformModelsByRuleSet([]PlatformModel{model}, rules, apiKeyAccessRuleSubjects(user), 0) + if len(actual) != test.want { + t.Fatalf("key %s received %d models, want %d", test.keyID, len(actual), test.want) + } + } +} + +func TestLayeredAccessDenyWinsAndNoRulesInherit(t *testing.T) { + model := PlatformModel{ID: "model-1", PlatformID: "platform-1", ModelType: StringList{"text_generate"}} + keyUser := &auth.User{APIKeyID: "key-a", APIKeyScopes: []string{"chat"}} + keyUsers := map[string]*auth.User{"key-a": keyUser} + if got := filterPlatformModelsByLayeredRuleSet(keyUser, []PlatformModel{model}, nil, nil, keyUsers); len(got) != 1 { + t.Fatalf("key without rules did not inherit baseline: %+v", got) + } + rules := []AccessRule{ + {SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow"}, + {SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "deny"}, + } + if got := filterPlatformModelsByLayeredRuleSet(keyUser, []PlatformModel{model}, nil, rules, keyUsers); len(got) != 0 { + t.Fatalf("matching deny did not override allow: %+v", got) + } +} + +func TestDirectUserIgnoresAPIKeyRules(t *testing.T) { + model := PlatformModel{ID: "model-1", PlatformID: "platform-1", ModelType: StringList{"text_generate"}} + rules := []AccessRule{{ + SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow", + }} + keyUsers := map[string]*auth.User{"key-a": {APIKeyID: "key-a", APIKeyScopes: []string{"chat"}}} + if got := filterPlatformModelsByLayeredRuleSet(&auth.User{GatewayUserID: "user-1"}, []PlatformModel{model}, nil, rules, keyUsers); len(got) != 1 { + t.Fatalf("direct user was constrained by API key exclusive rule: %+v", got) + } +} + +func TestIneffectiveAPIKeyRuleDoesNotControlAnotherKey(t *testing.T) { + model := PlatformModel{ID: "model-1", PlatformID: "platform-1", ModelType: StringList{"text_generate"}} + staleRule := AccessRule{SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "allow"} + groupDeny := AccessRule{SubjectType: "user_group", SubjectID: "group-a", ResourceType: "platform_model", ResourceID: "model-1", Effect: "deny"} + keyUsers := map[string]*auth.User{ + "key-a": {APIKeyID: "key-a", UserGroupID: "group-a", APIKeyScopes: []string{"chat"}}, + } + keyB := &auth.User{APIKeyID: "key-b", UserGroupID: "group-b", APIKeyScopes: []string{"chat"}} + if got := filterPlatformModelsByLayeredRuleSet(keyB, []PlatformModel{model}, []AccessRule{groupDeny}, []AccessRule{staleRule}, keyUsers); len(got) != 1 { + t.Fatalf("stale key-a rule blocked authorized key-b: %+v", got) + } +} + +func TestPlatformRuleOnlyControlsModelsItsKeyCanAccess(t *testing.T) { + allowed := PlatformModel{ID: "allowed", PlatformID: "platform-1", ModelType: StringList{"image_generate"}} + denied := PlatformModel{ID: "denied", PlatformID: "platform-1", ModelType: StringList{"text_generate"}} + rule := AccessRule{SubjectType: "api_key", SubjectID: "key-a", ResourceType: "platform", ResourceID: "platform-1", Effect: "allow"} + groupDeny := AccessRule{SubjectType: "user_group", SubjectID: "group-a", ResourceType: "platform_model", ResourceID: "denied", Effect: "deny"} + ruleUser := &auth.User{APIKeyID: "key-a", UserGroupID: "group-a", APIKeyScopes: []string{"image"}} + users := map[string]*auth.User{"key-a": ruleUser} + if got := effectiveAPIKeyRulesForPlatformModel([]AccessRule{rule}, []AccessRule{groupDeny}, users, allowed); len(got) != 1 { + t.Fatalf("platform rule should control the allowed image model: %+v", got) + } + if got := effectiveAPIKeyRulesForPlatformModel([]AccessRule{rule}, []AccessRule{groupDeny}, users, denied); len(got) != 0 { + t.Fatalf("platform rule controlled a group-denied or scope-denied model: %+v", got) + } +} + +func TestFilterPlatformModelsByAPIKeyScopesPrunesCapabilities(t *testing.T) { + models := []PlatformModel{{ + ID: "model-1", + ModelType: StringList{"text_generate", "image_generate"}, + Capabilities: map[string]any{ + "text_generate": map[string]any{"max_context_tokens": 128000}, + "image_generate": map[string]any{"aspect_ratio_allowed": []any{"1:1"}}, + "originalTypes": []any{"text_generate", "image_generate"}, + "shared": true, + }, + }} + actual := filterPlatformModelsByAPIKeyScopes(models, []string{"image"}) + if len(actual) != 1 || !reflect.DeepEqual(actual[0].ModelType, StringList{"image_generate"}) { + t.Fatalf("scope-filtered models = %+v", actual) + } + if _, exists := actual[0].Capabilities["text_generate"]; exists { + t.Fatalf("text capability leaked into image scope: %+v", actual[0].Capabilities) + } + if _, exists := actual[0].Capabilities["image_generate"]; !exists { + t.Fatalf("image capability was removed: %+v", actual[0].Capabilities) + } + if !reflect.DeepEqual(actual[0].Capabilities["originalTypes"], []string{"image_generate"}) { + t.Fatalf("originalTypes not pruned: %+v", actual[0].Capabilities["originalTypes"]) + } + if actual[0].Capabilities["shared"] != true { + t.Fatalf("shared capability metadata was removed: %+v", actual[0].Capabilities) + } +} + +func TestDiagnoseAPIKeyRulesExplainsEachInactiveLayer(t *testing.T) { + rules := []AccessRule{ + {ID: "gone", SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "gone", Effect: "allow", Status: "active"}, + {ID: "revoked", SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "revoked", Effect: "allow", Status: "active"}, + {ID: "scope", SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "scope", Effect: "deny", Status: "active"}, + {ID: "effective", SubjectType: "api_key", SubjectID: "key-1", ResourceType: "platform_model", ResourceID: "effective", Effect: "allow", Status: "active"}, + } + all := []PlatformModel{{ID: "revoked"}, {ID: "scope"}, {ID: "effective"}} + enabled := append([]PlatformModel(nil), all...) + baseline := []PlatformModel{{ID: "scope"}, {ID: "effective"}} + scoped := []PlatformModel{{ID: "effective"}} + diagnostics := diagnoseAPIKeyRules("key-1", rules, all, enabled, baseline, scoped, nil) + want := map[string]string{ + "gone": "resource_unavailable", "revoked": "owner_access_revoked", "scope": "scope_not_allowed", "effective": "", + } + for _, diagnostic := range diagnostics { + if diagnostic.Reason != want[diagnostic.RuleID] { + t.Fatalf("diagnostic %s reason = %q, want %q", diagnostic.RuleID, diagnostic.Reason, want[diagnostic.RuleID]) + } + if diagnostic.Effective != (diagnostic.RuleID == "effective") { + t.Fatalf("diagnostic %s effective = %v", diagnostic.RuleID, diagnostic.Effective) + } + } +} diff --git a/apps/api/internal/store/access_rules.go b/apps/api/internal/store/access_rules.go index 73f1ef2..00d7166 100644 --- a/apps/api/internal/store/access_rules.go +++ b/apps/api/internal/store/access_rules.go @@ -244,7 +244,7 @@ SELECT EXISTS ( if !exists { return nil, pgx.ErrNoRows } - if err := s.ensureAPIKeyAccessRuleResourcesAllowed(ctx, user, input.UpsertResources); err != nil { + if err := s.ensureAPIKeyAccessRuleResourcesAllowed(ctx, user, input.SubjectID, input.UpsertResources); err != nil { return nil, err } if _, err := s.BatchAccessRules(ctx, input); err != nil { @@ -254,32 +254,7 @@ SELECT EXISTS ( } func (s *Store) filterCandidatesByAccessRules(ctx context.Context, user *auth.User, candidates []RuntimeModelCandidate) ([]RuntimeModelCandidate, error) { - if len(candidates) == 0 { - return candidates, nil - } - resources := candidateAccessResources(candidates) - if len(resources) == 0 { - return candidates, nil - } - rules, err := s.listActiveAccessRulesForResources(ctx, resources) - if err != nil { - return nil, err - } - if len(rules) == 0 { - return candidates, nil - } - subjects := accessRuleSubjects(user) - level := 0 - if user != nil { - level = auth.PermissionLevel(user.Roles) - } - filtered := candidates[:0] - for _, candidate := range candidates { - if candidateAllowedByAccessRules(candidate, rules, subjects, level) { - filtered = append(filtered, candidate) - } - } - return filtered, nil + return s.filterRuntimeCandidatesByLayeredAccess(ctx, user, candidates) } func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) { @@ -302,35 +277,22 @@ func (s *Store) listPlatformModelsForAccessRules(ctx context.Context, user *auth if err != nil { return nil, err } - models, err := s.ListModels(ctx) + models, _, err := s.enabledPlatformModels(ctx) if err != nil { return nil, err } - platforms, err := s.ListPlatforms(ctx) - if err != nil { - return nil, err + if excludedSubjectTypes["api_key"] { + return s.filterPlatformModelsByBaselineAccess(ctx, accessUser, models) } - enabledPlatforms := map[string]bool{} - for _, platform := range platforms { - if platform.Status == "enabled" { - enabledPlatforms[platform.ID] = true - } - } - enabled := make([]PlatformModel, 0, len(models)) - for _, model := range models { - if model.Enabled && enabledPlatforms[model.PlatformID] { - enabled = append(enabled, model) - } - } - return s.filterPlatformModelsByAccessRules(ctx, accessUser, enabled, excludedSubjectTypes) + return s.filterPlatformModelsByLayeredAccess(ctx, accessUser, models) } -func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user *auth.User, resources []AccessRuleResourceInput) error { +func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user *auth.User, apiKeyID string, resources []AccessRuleResourceInput) error { resources = dedupeAccessRuleResources(resources) if len(resources) == 0 { return nil } - allowed, err := s.accessibleAccessRuleResources(ctx, user) + allowed, err := s.accessibleAccessRuleResources(ctx, user, apiKeyID) if err != nil { return err } @@ -342,8 +304,8 @@ func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user return nil } -func (s *Store) accessibleAccessRuleResources(ctx context.Context, user *auth.User) (map[string]bool, error) { - models, err := s.ListAPIKeyAssignablePlatformModels(ctx, user) +func (s *Store) accessibleAccessRuleResources(ctx context.Context, user *auth.User, apiKeyID string) (map[string]bool, error) { + models, _, err := s.ListAPIKeyAssignablePlatformModelsForKey(ctx, user, apiKeyID) if err != nil { return nil, err } @@ -368,25 +330,28 @@ func (s *Store) resolveCurrentAccessUser(ctx context.Context, user *auth.User) ( } next := *user var userGroupID string + var userGroupKey string var err error if strings.TrimSpace(user.APIKeyID) != "" { err = s.pool.QueryRow(ctx, ` -SELECT COALESCE(k.user_group_id::text, u.default_user_group_id::text, '') +SELECT COALESCE(k.user_group_id::text, u.default_user_group_id::text, ''), COALESCE(g.group_key, '') FROM gateway_users u JOIN gateway_api_keys k ON k.gateway_user_id = u.id +LEFT JOIN gateway_user_groups g ON g.id = COALESCE(k.user_group_id, u.default_user_group_id) WHERE u.id = $1::uuid AND k.id = $2::uuid AND u.status = 'active' AND u.deleted_at IS NULL AND k.status = 'active' - AND k.deleted_at IS NULL`, gatewayUserID, user.APIKeyID).Scan(&userGroupID) + AND k.deleted_at IS NULL`, gatewayUserID, user.APIKeyID).Scan(&userGroupID, &userGroupKey) } else { err = s.pool.QueryRow(ctx, ` -SELECT COALESCE(default_user_group_id::text, '') -FROM gateway_users -WHERE id = $1::uuid - AND status = 'active' - AND deleted_at IS NULL`, gatewayUserID).Scan(&userGroupID) +SELECT COALESCE(u.default_user_group_id::text, ''), COALESCE(g.group_key, '') +FROM gateway_users u +LEFT JOIN gateway_user_groups g ON g.id = u.default_user_group_id +WHERE u.id = $1::uuid + AND u.status = 'active' + AND u.deleted_at IS NULL`, gatewayUserID).Scan(&userGroupID, &userGroupKey) } if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -395,6 +360,11 @@ WHERE id = $1::uuid return nil, err } next.UserGroupID = userGroupID + next.UserGroupKey = userGroupKey + next.UserGroupKeys = nil + if userGroupKey != "" { + next.UserGroupKeys = []string{userGroupKey} + } return &next, nil } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6020de1..fc2cd52 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -73,7 +73,6 @@ import { listAdminTasks, listAuditLogs, listApiKeyAccessRules, - listApiKeyAssignableModels, listApiKeys, listBaseModels, listCatalogProviders, @@ -186,7 +185,6 @@ type DataKey = | 'publicCatalog' | 'playgroundApiKeys' | 'playgroundModels' - | 'apiKeyPolicyModels' | 'modelCatalog' | 'networkProxyConfig' | 'clientCustomizationSettings' @@ -241,7 +239,6 @@ export function App() { summary: { modelCount: 0, sourceCount: 0 }, }); const [playgroundModels, setPlaygroundModels] = useState([]); - const [apiKeyPolicyModels, setApiKeyPolicyModels] = useState([]); const [networkProxyConfig, setNetworkProxyConfig] = useState(null); const [clientCustomizationSettings, setClientCustomizationSettings] = useState(null); const [fileStorageChannels, setFileStorageChannels] = useState([]); @@ -563,9 +560,6 @@ export function App() { case 'playgroundModels': setPlaygroundModels((await listPlayableModels(nextToken)).items); return; - case 'apiKeyPolicyModels': - setApiKeyPolicyModels((await listApiKeyAssignableModels(nextToken)).items); - return; case 'playgroundApiKeys': { const response = await listPlayableApiKeys(nextToken); setApiKeys(response.items); @@ -717,7 +711,7 @@ export function App() { try { const response = await createApiKey(token, { name: apiKeyForm.name, - scopes: ['chat', 'embedding', 'rerank', 'image', 'video', 'music', 'audio'], + scopes: ['chat', 'embedding', 'rerank', 'image', 'image_vectorize', 'video', 'video_enhance', 'music', 'audio', 'voice_clone'], expiresAt: apiKeyForm.expiresAt ? new Date(apiKeyForm.expiresAt).toISOString() : undefined, }); setApiKeySecret(response.secret); @@ -750,7 +744,7 @@ export function App() { const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings); setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]); setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]); - invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels'); + invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels'); setCoreState('ready'); setCoreMessage(input.platformId ? `平台已更新,当前绑定 ${input.models.length} 个模型。` @@ -770,7 +764,7 @@ export function App() { const updated = await updatePlatform(token, platform.id, input); const platformForState = withCredentialPreviewFallback(updated, input, platform); setPlatforms((current) => current.map((item) => item.id === platform.id ? platformForState : item)); - invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels'); + invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels'); setCoreState('ready'); setCoreMessage(status === 'enabled' ? '平台已启用。' : '平台已禁用。'); } catch (err) { @@ -802,7 +796,7 @@ export function App() { platformPriority: state.priority, } : status)); - invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels', 'apiKeyPolicyModels'); + invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels'); setCoreState('ready'); setCoreMessage(input.reset ? '平台动态优先级已重置。' : '平台动态优先级已更新。'); } catch (err) { @@ -846,7 +840,7 @@ export function App() { cooldownUntil: undefined, } : model)); - invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels', 'apiKeyPolicyModels'); + invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels'); setCoreState('ready'); setCoreMessage('模型运行状态已恢复。'); } catch (err) { @@ -863,7 +857,7 @@ export function App() { await deletePlatform(token, platformId); setPlatforms((current) => current.filter((item) => item.id !== platformId)); setModels((current) => current.filter((item) => item.platformId !== platformId)); - invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels'); + invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels'); setCoreState('ready'); setCoreMessage('平台已删除。'); } catch (err) { @@ -879,7 +873,7 @@ export function App() { try { const item = tenantId ? await updateTenant(token, tenantId, input) : await createTenant(token, input); setTenants((current) => [item, ...current.filter((tenant) => tenant.id !== item.id)]); - invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels'); + invalidateDataKeys('playgroundModels'); setCoreState('ready'); setCoreMessage(tenantId ? '租户已更新。' : '租户已创建。'); } catch (err) { @@ -895,7 +889,7 @@ export function App() { try { await deleteTenant(token, tenantId); setTenants((current) => current.filter((tenant) => tenant.id !== tenantId)); - invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels'); + invalidateDataKeys('playgroundModels'); setCoreState('ready'); setCoreMessage('租户已删除。'); } catch (err) { @@ -911,7 +905,7 @@ export function App() { try { const item = userId ? await updateGatewayUser(token, userId, input) : await createGatewayUser(token, input); setUsers((current) => [item, ...current.filter((user) => user.id !== item.id)]); - invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels'); + invalidateDataKeys('playgroundModels'); setCoreState('ready'); setCoreMessage(userId ? '用户已更新。' : '用户已创建。'); } catch (err) { @@ -967,7 +961,7 @@ export function App() { try { const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input); setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]); - invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels'); + invalidateDataKeys('modelCatalog', 'playgroundModels'); setCoreState('ready'); setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。'); } catch (err) { @@ -985,7 +979,7 @@ export function App() { setUserGroups((current) => current.filter((group) => group.id !== groupId)); setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant)); setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user)); - invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels'); + invalidateDataKeys('modelCatalog', 'playgroundModels'); setCoreState('ready'); setCoreMessage('用户组已删除。'); } catch (err) { @@ -1039,7 +1033,7 @@ export function App() { try { const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input); setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]); - invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog'); + invalidateDataKeys('playgroundModels', 'modelCatalog'); setCoreState('ready'); setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。'); } catch (err) { @@ -1055,7 +1049,7 @@ export function App() { try { await deleteAccessRule(token, ruleId); setAccessRules((current) => current.filter((rule) => rule.id !== ruleId)); - invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog'); + invalidateDataKeys('playgroundModels', 'modelCatalog'); setCoreState('ready'); setCoreMessage('访问权限规则已删除。'); } catch (err) { @@ -1071,7 +1065,7 @@ export function App() { try { const response = await batchAccessRules(token, input); setAccessRules(response.items); - invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog'); + invalidateDataKeys('playgroundModels', 'modelCatalog'); setCoreState('ready'); setCoreMessage('访问权限已更新。'); } catch (err) { @@ -1459,7 +1453,6 @@ export function App() { apiKeyForm={apiKeyForm} apiKeySecret={apiKeySecret} apiKeySecretsById={apiKeySecretsById} - apiKeyPolicyModels={apiKeyPolicyModels} data={data} message={coreMessage} section={workspaceSection} @@ -1733,7 +1726,7 @@ function dataKeysForRoute( if (activePage === 'workspace') { if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys']; if (workspaceSection === 'billing') return ['wallet']; - if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'apiKeyPolicyModels']; + if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules']; if (workspaceSection === 'tasks') return ['tasks']; if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions']; return []; diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index 4b7f88c..f136006 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -238,17 +238,17 @@ describe('API Key permission resources', () => { vi.unstubAllGlobals(); }); - it('loads the user-owned resource pool independently from playable models', async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ items: [] }), { + it('loads the key-scoped resource pool independently from playable models', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ items: [], ruleDiagnostics: [] }), { status: 200, headers: { 'Content-Type': 'application/json' }, })); vi.stubGlobal('fetch', fetchMock); - await listApiKeyAssignableModels('user-token'); + await listApiKeyAssignableModels('user-token', 'key-1'); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toContain('/api/v1/api-keys/assignable-models'); + expect(url).toContain('/api/v1/api-keys/key-1/assignable-models'); expect(new Headers(init.headers).get('Authorization')).toBe('Bearer user-token'); }); }); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index fcd3046..895a97f 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -18,6 +18,7 @@ import type { GatewayAccessRuleBatchRequest, GatewayAccessRule, GatewayAccessRuleUpsertRequest, + GatewayAPIKeyAssignableModelsResponse, GatewayApiKey, GatewayApiKeyScopeUpdateRequest, GatewayAuditLog, @@ -159,7 +160,7 @@ export async function listModels(token: string): Promise> { - return request>('/api/v1/models', { token }); + return request>('/api/v1/platform-models', { token }); } export async function listModelCatalog(token: string): Promise { @@ -461,8 +462,8 @@ export async function listApiKeyAccessRules(token: string): Promise>('/api/v1/api-keys/access-rules', { token }); } -export async function listApiKeyAssignableModels(token: string): Promise> { - return request>('/api/v1/api-keys/assignable-models', { token }); +export async function listApiKeyAssignableModels(token: string, apiKeyId: string): Promise { + return request(`/api/v1/api-keys/${apiKeyId}/assignable-models`, { token }); } export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise { diff --git a/apps/web/src/pages/ApiDocsPage.test.tsx b/apps/web/src/pages/ApiDocsPage.test.tsx index 5f2053c..a32750d 100644 --- a/apps/web/src/pages/ApiDocsPage.test.tsx +++ b/apps/web/src/pages/ApiDocsPage.test.tsx @@ -8,10 +8,10 @@ describe('ApiDocsPage extended task documentation', () => { it('separates the complete public catalog into common and compatibility interfaces', () => { const endpoints = publicApiCatalogGroups.flatMap((group) => group.endpoints); - expect(publicApiEndpointCount()).toBe(71); - expect(publicApiEndpointCount('open')).toBe(54); + expect(publicApiEndpointCount()).toBe(73); + expect(publicApiEndpointCount('open')).toBe(56); expect(publicApiEndpointCount('compatibility')).toBe(17); - expect(endpoints.every((endpoint) => endpoint.path.startsWith('/api/v1/'))).toBe(true); + expect(endpoints.every((endpoint) => endpoint.path.startsWith('/api/v1/') || endpoint.path === '/v1/models')).toBe(true); }); it('renders the common open API catalog as a complete onboarding page', () => { @@ -21,7 +21,7 @@ describe('ApiDocsPage extended task documentation', () => { expect(html).toContain('兼容接口'); expect(html).toContain('https://ai.51easyai.com/api/v1'); expect(html).toContain('接口数量'); - expect(html).toContain('>54<'); + expect(html).toContain('>56<'); expect(html).toContain('/api/v1/videos/generations'); expect(html).toContain('/api/v1/resource/material'); expect(html).toContain('不要求调用方显式传入'); diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx index 419da7c..fa1afbf 100644 --- a/apps/web/src/pages/WorkspacePage.tsx +++ b/apps/web/src/pages/WorkspacePage.tsx @@ -1,13 +1,13 @@ import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react'; import { Popover as AntPopover } from 'antd'; import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, ReceiptText, RotateCcw, Search, ShieldCheck, SlidersHorizontal, Trash2, UserRound } from 'lucide-react'; -import type { AdminGatewayTask, GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayApiKeyScopeUpdateRequest, GatewayTask, GatewayTaskParamPreprocessingLog, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; +import type { AdminGatewayTask, GatewayAccessRuleBatchRequest, GatewayAPIKeyAccessRuleDiagnostic, GatewayApiKey, GatewayApiKeyScopeUpdateRequest, GatewayTask, GatewayTaskParamPreprocessingLog, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; import type { ConsoleData } from '../app-state'; import { EntityTable } from '../components/EntityTable'; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, ConfirmDialog, DateTimePicker, DateTimeRangePicker, FormDialog, Input, Label, Select, Table, TableCell, TableFooter, TableHead, TablePageActions, TableRow, TableToolbar, TableViewportLayout, Tabs } from '../components/ui'; import { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor'; import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery, WorkspaceTransactionQuery } from '../types'; -import { listTaskParamPreprocessing } from '../api'; +import { listApiKeyAssignableModels, listTaskParamPreprocessing } from '../api'; const tabs = [ { value: 'overview', label: '个人总览', icon: }, @@ -24,9 +24,12 @@ const apiKeyScopeOptions = [ { value: 'embedding', label: '向量', description: 'Embeddings' }, { value: 'rerank', label: '重排', description: 'Reranks' }, { value: 'image', label: '图像', description: 'Images' }, + { value: 'image_vectorize', label: '图像向量化', description: 'Image Vectorize' }, { value: 'video', label: '视频', description: 'Videos' }, + { value: 'video_enhance', label: '视频增强', description: 'Video Upscale / Enhance' }, { value: 'music', label: '音乐生成', description: 'Song / Music' }, { value: 'audio', label: '语音合成', description: 'Speech / TTS' }, + { value: 'voice_clone', label: '声音克隆', description: 'Voice Clone' }, { value: 'all', label: '全部能力', description: '不按接口能力限制' }, ] as const; @@ -36,7 +39,6 @@ export function WorkspacePage(props: { apiKeyForm: ApiKeyForm; apiKeySecret: string; apiKeySecretsById: Record; - apiKeyPolicyModels: PlatformModel[]; data: ConsoleData; message: string; section: WorkspaceSection; @@ -389,10 +391,10 @@ function ApiKeyPanel(props: { apiKeyForm: ApiKeyForm; apiKeySecret: string; apiKeySecretsById: Record; - apiKeyPolicyModels: PlatformModel[]; data: ConsoleData; message: string; state: LoadState; + token: string; onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; onDeleteApiKey: (apiKeyId: string) => Promise; onApiKeyFormChange: (value: ApiKeyForm) => void; @@ -407,6 +409,10 @@ function ApiKeyPanel(props: { const [scopeError, setScopeError] = useState(''); const [pendingDelete, setPendingDelete] = useState(null); const [localMessage, setLocalMessage] = useState(''); + const [policyModels, setPolicyModels] = useState([]); + const [policyDiagnostics, setPolicyDiagnostics] = useState([]); + const [policyState, setPolicyState] = useState('idle'); + const [policyError, setPolicyError] = useState(''); const selectedPolicyKey = useMemo( () => props.data.apiKeys.find((item) => item.id === policyApiKeyId), [policyApiKeyId, props.data.apiKeys], @@ -415,7 +421,44 @@ function ApiKeyPanel(props: { () => props.data.apiKeys.find((item) => item.id === scopeApiKeyId), [scopeApiKeyId, props.data.apiKeys], ); - const permissionPlatforms = useMemo(() => platformsForPermissionTree(props.apiKeyPolicyModels), [props.apiKeyPolicyModels]); + const permissionPlatforms = useMemo(() => platformsForPermissionTree(policyModels), [policyModels]); + + useEffect(() => { + if (!policyApiKeyId) { + setPolicyModels([]); + setPolicyDiagnostics([]); + setPolicyState('idle'); + setPolicyError(''); + return; + } + let cancelled = false; + setPolicyState('loading'); + setPolicyError(''); + void listApiKeyAssignableModels(props.token, policyApiKeyId).then((response) => { + if (cancelled) return; + setPolicyModels(response.items); + setPolicyDiagnostics(response.ruleDiagnostics); + setPolicyState('ready'); + }).catch((error) => { + if (cancelled) return; + setPolicyModels([]); + setPolicyDiagnostics([]); + setPolicyState('error'); + setPolicyError(error instanceof Error ? error.message : 'API Key 可分配模型加载失败'); + }); + return () => { + cancelled = true; + }; + }, [policyApiKeyId, props.token]); + + async function savePolicyRules(input: GatewayAccessRuleBatchRequest) { + await props.onBatchAccessRules(input); + if (!policyApiKeyId) return; + const response = await listApiKeyAssignableModels(props.token, policyApiKeyId); + setPolicyModels(response.items); + setPolicyDiagnostics(response.ruleDiagnostics); + setPolicyState('ready'); + } async function copyApiKey(item: GatewayApiKey) { const secret = apiKeySecretFor(item, props.apiKeySecretsById); @@ -620,15 +663,17 @@ function ApiKeyPanel(props: { onClose={() => setPolicyApiKeyId('')} onSubmit={(event) => event.preventDefault()} > + {policyError &&

{policyError}

} diff --git a/apps/web/src/pages/admin/AccessPermissionEditor.test.tsx b/apps/web/src/pages/admin/AccessPermissionEditor.test.tsx new file mode 100644 index 0000000..6adf893 --- /dev/null +++ b/apps/web/src/pages/admin/AccessPermissionEditor.test.tsx @@ -0,0 +1,63 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import type { IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; +import { AccessPermissionEditor } from './AccessPermissionEditor'; + +describe('AccessPermissionEditor API Key diagnostics', () => { + it('shows retained ineffective rules and only the scope-pruned model capabilities', () => { + const platform = { + id: 'platform-1', + provider: 'openai', + platformKey: 'scope-test', + name: 'Scope Test', + authType: 'bearer', + status: 'enabled', + priority: 0, + effectivePriority: 0, + defaultPricingMode: 'inherit', + defaultDiscountFactor: 1, + createdAt: '2026-08-03T00:00:00Z', + updatedAt: '2026-08-03T00:00:00Z', + } satisfies IntegrationPlatform; + const model = { + id: 'model-1', + platformId: platform.id, + modelName: 'multi-model', + modelType: ['image_generate'], + displayName: 'Multi Model', + pricingMode: 'inherit', + rateLimitPolicyMode: 'inherit', + enabled: true, + createdAt: '2026-08-03T00:00:00Z', + updatedAt: '2026-08-03T00:00:00Z', + } satisfies PlatformModel; + + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('1 条规则当前不生效'); + expect(html).toContain('Text Model'); + expect(html).toContain('不在当前 API Key 的能力范围内'); + expect(html).toContain('移除规则'); + expect(html).toContain('image_generate'); + expect(html).not.toContain('text_generate'); + }); +}); diff --git a/apps/web/src/pages/admin/AccessPermissionEditor.tsx b/apps/web/src/pages/admin/AccessPermissionEditor.tsx index 70013f8..0014753 100644 --- a/apps/web/src/pages/admin/AccessPermissionEditor.tsx +++ b/apps/web/src/pages/admin/AccessPermissionEditor.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { ChevronDown, ChevronRight } from 'lucide-react'; import type { GatewayAccessEffect, + GatewayAPIKeyAccessRuleDiagnostic, GatewayAccessRule, GatewayAccessRuleBatchRequest, GatewayAccessRuleResourceRequest, @@ -14,7 +15,7 @@ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, Inpu import type { LoadState } from '../../types'; type Effect = Extract; -type ResourceType = Extract; +type ResourceType = GatewayAccessResourceType; type ResourceKey = `${ResourceType}:${string}`; type PlatformNode = { @@ -34,6 +35,7 @@ export function AccessPermissionEditor(props: { metadataMode?: string; platformModels: PlatformModel[]; platforms: IntegrationPlatform[]; + ruleDiagnostics?: GatewayAPIKeyAccessRuleDiagnostic[]; state: LoadState; subjectId: string; subjectType: Extract; @@ -51,6 +53,10 @@ export function AccessPermissionEditor(props: { [props.accessRules, props.subjectId, props.subjectType], ); const ruleByEffectAndResource = useMemo(() => buildRuleIndex(subjectRules), [subjectRules]); + const ineffectiveRules = useMemo( + () => (props.ruleDiagnostics ?? []).filter((diagnostic) => !diagnostic.effective), + [props.ruleDiagnostics], + ); const allowTree = useMemo(() => filterTree(platformTree, allowSearch), [allowSearch, platformTree]); const denyTree = useMemo(() => filterTree(platformTree, denySearch), [denySearch, platformTree]); @@ -65,19 +71,20 @@ export function AccessPermissionEditor(props: { } async function setPlatformPermission(effect: Effect, platform: PlatformNode, enabled: boolean) { - const keys = [ - makeResourceKey('platform', platform.id), - ...platform.models.map((model) => makeResourceKey('platform_model', model.id)), - ]; - await batchApply(effect, keys, enabled); + const modelKeys = platform.models.map((model) => makeResourceKey('platform_model', model.id)); + if (enabled && props.subjectType === 'api_key') { + await batchApply(effect, modelKeys, true); + return; + } + await batchApply(effect, [makeResourceKey('platform', platform.id), ...modelKeys], enabled); } async function selectAll(effect: Effect, nodes: PlatformNode[]) { - await batchApply(effect, visibleResourceKeys(nodes), true); + await batchApply(effect, visibleResourceKeys(nodes, props.subjectType !== 'api_key'), true); } async function reverseVisible(effect: Effect, nodes: PlatformNode[]) { - const keys = visibleResourceKeys(nodes); + const keys = visibleResourceKeys(nodes, props.subjectType !== 'api_key'); const upsertKeys: ResourceKey[] = []; const deleteKeys: ResourceKey[] = []; for (const key of keys) { @@ -88,6 +95,16 @@ export function AccessPermissionEditor(props: { await applyPermissionBatch(effect, upsertKeys, deleteKeys); } + async function removeIneffectiveRule(diagnostic: GatewayAPIKeyAccessRuleDiagnostic) { + if (diagnostic.effect !== 'allow' && diagnostic.effect !== 'deny') return; + await applyPermissionBatch( + diagnostic.effect, + [], + [makeResourceKey(diagnostic.resourceType as ResourceType, diagnostic.resourceId)], + '失效规则移除失败', + ); + } + async function clearEffect(effect: Effect) { const keys = subjectRules .filter((rule) => rule.effect === effect) @@ -138,11 +155,32 @@ export function AccessPermissionEditor(props: { return (
{localError &&

{localError}

} + {ineffectiveRules.length > 0 && ( +
+
+ {ineffectiveRules.length} 条规则当前不生效 + 规则已保留,不会扩大权限或阻断其他 API Key。 +
+ {ineffectiveRules.map((diagnostic) => ( +
+ + {diagnostic.effect === 'allow' ? '专属' : '排除'} + {diagnostic.resourceName || diagnostic.resourceId} + {accessRuleDiagnosticReason(diagnostic.reason)} + + +
+ ))} +
+ )}
; + modelOnlySelection: boolean; rules: Map; search: string; state: LoadState; @@ -229,6 +269,7 @@ function PermissionTreePanel(props: { key={platform.id} platform={platform} rules={props.rules} + modelOnlySelection={props.modelOnlySelection} onToggleExpanded={props.onToggleExpanded} onTogglePlatformPermission={props.onTogglePlatformPermission} onTogglePermission={props.onTogglePermission} @@ -245,6 +286,7 @@ function PlatformPermissionNode(props: { expanded: boolean; platform: PlatformNode; rules: Map; + modelOnlySelection: boolean; onToggleExpanded: (platformId: string) => void; onTogglePlatformPermission: (effect: Effect, platform: PlatformNode, enabled: boolean) => void; onTogglePermission: (effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) => void; @@ -252,7 +294,17 @@ function PlatformPermissionNode(props: { const platformRuleKey = `${props.effect}:${makeResourceKey('platform', props.platform.id)}`; const checkedModels = props.platform.models.filter((model) => props.rules.has(`${props.effect}:${makeResourceKey('platform_model', model.id)}`)).length; const platformChecked = props.rules.has(platformRuleKey); - const platformState = platformChecked ? true : checkedModels > 0 ? 'indeterminate' : false; + const platformState = props.modelOnlySelection + ? checkedModels > 0 && checkedModels === props.platform.models.length + ? true + : checkedModels > 0 || platformChecked + ? 'indeterminate' + : false + : platformChecked + ? true + : checkedModels > 0 + ? 'indeterminate' + : false; return (
@@ -352,9 +404,9 @@ function countEffectRules(rules: GatewayAccessRule[], effect: Effect) { }, { platforms: 0, models: 0 }); } -function visibleResourceKeys(nodes: PlatformNode[]): ResourceKey[] { +function visibleResourceKeys(nodes: PlatformNode[], includePlatforms: boolean): ResourceKey[] { return nodes.flatMap((platform) => [ - makeResourceKey('platform', platform.id), + ...(includePlatforms ? [makeResourceKey('platform', platform.id)] : []), ...platform.models.map((model) => makeResourceKey('platform_model', model.id)), ]); } @@ -382,10 +434,23 @@ function resourceRequestFromKey(resourceKey: ResourceKey): GatewayAccessRuleReso } function resourceKeyFromRule(rule: GatewayAccessRule): ResourceKey | undefined { - if (rule.resourceType !== 'platform' && rule.resourceType !== 'platform_model') return undefined; + if (rule.resourceType !== 'platform' && rule.resourceType !== 'platform_model' && rule.resourceType !== 'base_model') return undefined; return makeResourceKey(rule.resourceType, rule.resourceId); } +function accessRuleDiagnosticReason(reason?: string) { + switch (reason) { + case 'resource_unavailable': + return '平台或模型当前未启用'; + case 'owner_access_revoked': + return '已被用户组、租户或用户权限收回'; + case 'scope_not_allowed': + return '不在当前 API Key 的能力范围内'; + default: + return '当前规则不生效'; + } +} + function dedupeResourceKeys(keys: ResourceKey[]) { return Array.from(new Set(keys.filter(Boolean))); } diff --git a/apps/web/src/pages/api-docs-catalog.ts b/apps/web/src/pages/api-docs-catalog.ts index 79d96dd..b31ae10 100644 --- a/apps/web/src/pages/api-docs-catalog.ts +++ b/apps/web/src/pages/api-docs-catalog.ts @@ -63,7 +63,7 @@ export const publicApiCatalogGroups: PublicApiCatalogGroup[] = [ { method: 'POST', path: '/api/v1/api-keys', description: '创建 API Key' }, { method: 'GET', path: '/api/v1/api-keys/access-rules', description: '查询 Key 访问规则' }, { method: 'POST', path: '/api/v1/api-keys/access-rules/batch', description: '批量设置 Key 访问规则' }, - { method: 'GET', path: '/api/v1/api-keys/assignable-models', description: '查询可分配模型' }, + { method: 'GET', path: '/api/v1/api-keys/{apiKeyID}/assignable-models', description: '查询指定 Key 可分配模型与失效规则' }, { method: 'PATCH', path: '/api/v1/api-keys/{apiKeyID}/scopes', description: '更新 Key 权限范围' }, { method: 'PATCH', path: '/api/v1/api-keys/{apiKeyID}/disable', description: '禁用 Key' }, { method: 'DELETE', path: '/api/v1/api-keys/{apiKeyID}', description: '删除 Key' }, @@ -77,7 +77,9 @@ export const publicApiCatalogGroups: PublicApiCatalogGroup[] = [ endpoints: [ { method: 'GET', path: '/api/v1/model-catalog', description: '模型能力目录' }, { method: 'GET', path: '/api/v1/platforms', description: '当前用户可用平台' }, - { method: 'GET', path: '/api/v1/models', description: '当前用户可用模型' }, + { method: 'GET', path: '/api/v1/platform-models', description: '当前身份可用的平台模型来源明细' }, + { method: 'GET', path: '/v1/models', description: 'OpenAI 兼容逻辑模型列表' }, + { method: 'GET', path: '/api/v1/models', description: '已弃用的平台模型来源明细' }, { method: 'GET', path: '/api/v1/playground/models', description: 'Playground 可用模型' }, { method: 'POST', path: '/api/v1/pricing/estimate', description: '请求价格预估' }, ], diff --git a/apps/web/src/styles/pages.css b/apps/web/src/styles/pages.css index 5dafd79..6cfadcf 100644 --- a/apps/web/src/styles/pages.css +++ b/apps/web/src/styles/pages.css @@ -467,6 +467,48 @@ gap: 12px; } +.accessRuleDiagnostics { + display: grid; + gap: 0.625rem; + padding: 0.75rem; + border: 1px solid var(--warning-border, var(--border-subtle)); + border-radius: var(--radius-md); + background: var(--surface-subtle); +} + +.accessRuleDiagnostics > div:first-child, +.accessRuleDiagnosticRow, +.accessRuleDiagnosticRow > span { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.accessRuleDiagnostics > div:first-child { + flex-wrap: wrap; + color: var(--muted-foreground); + font-size: var(--font-size-sm); +} + +.accessRuleDiagnostics > div:first-child strong { + color: var(--foreground); +} + +.accessRuleDiagnosticRow { + justify-content: space-between; + padding-top: 0.625rem; + border-top: 1px solid var(--border-subtle); +} + +.accessRuleDiagnosticRow > span { + min-width: 0; + flex-wrap: wrap; +} + +.accessRuleDiagnosticRow small { + color: var(--muted-foreground); +} + .accessPermissionPanel .shCardContent { display: grid; gap: 12px; diff --git a/docs/public-api-v1.md b/docs/public-api-v1.md index e449343..762a188 100644 --- a/docs/public-api-v1.md +++ b/docs/public-api-v1.md @@ -39,7 +39,7 @@ https://ai.51easyai.com/api/v1 | GET, POST | `/api/v1/api-keys` | 查询、创建 API Key | | GET | `/api/v1/api-keys/access-rules` | 查询 Key 访问规则 | | POST | `/api/v1/api-keys/access-rules/batch` | 批量设置 Key 访问规则 | -| GET | `/api/v1/api-keys/assignable-models` | 查询可分配模型 | +| GET | `/api/v1/api-keys/{apiKeyID}/assignable-models` | 查询指定 Key 可分配模型与规则诊断 | | PATCH | `/api/v1/api-keys/{apiKeyID}/scopes` | 更新 Key 权限范围 | | PATCH | `/api/v1/api-keys/{apiKeyID}/disable` | 禁用 Key | | DELETE | `/api/v1/api-keys/{apiKeyID}` | 删除 Key | @@ -50,7 +50,9 @@ https://ai.51easyai.com/api/v1 |---|---|---| | GET | `/api/v1/model-catalog` | 模型能力目录 | | GET | `/api/v1/platforms` | 当前用户可用平台 | -| GET | `/api/v1/models` | 当前用户可用模型 | +| GET | `/api/v1/platform-models` | 当前身份可用的平台模型来源明细 | +| GET | `/v1/models` | OpenAI 兼容逻辑模型列表 | +| GET | `/api/v1/models` | 已弃用的平台模型来源明细(兼容期) | | GET | `/api/v1/playground/models` | Playground 可用模型 | | POST | `/api/v1/pricing/estimate` | 请求价格预估 | diff --git a/docs/test/loopback-test-checklist.md b/docs/test/loopback-test-checklist.md index 2061a0a..cdd4e57 100644 --- a/docs/test/loopback-test-checklist.md +++ b/docs/test/loopback-test-checklist.md @@ -55,7 +55,7 @@ | --- | --- | --- | --- | --- | --- | | SETUP-01 | 确认服务可用 | `GET /api/v1/healthz`、`GET /api/v1/readyz` | `healthz.ok=true`,`readyz.ok=true` | 未执行 | 待填写 | | SETUP-02 | 准备管理员权限 | 本地注册 / 登录,必要时将测试用户提升为 `admin` 或 `manager` | `GET /api/v1/me` 返回 `role` 具备 `manager` 权限 | 未执行 | 待填写 | -| SETUP-03 | 记录用户提供的真实平台、模型和 KEY | `GET /api/v1/platforms`、`GET /api/v1/models` | Chat 模型、`doubao-4.5图像编辑`、`豆包Seedance-1.5-pro` 均已启用,并能被管理员看到 | 未执行 | 待填写 | +| SETUP-03 | 记录用户提供的真实平台、模型和 KEY | `GET /api/v1/platforms`、`GET /api/v1/platform-models`、`GET /v1/models` | Chat 模型、`doubao-4.5图像编辑`、`豆包Seedance-1.5-pro` 均已启用,并能被管理员看到 | 未执行 | 待填写 | | SETUP-04 | 创建内部测试用户组 | `POST /api/v1/user-groups` | 创建 `loopback-allow-group`、`loopback-deny-group`、`loopback-limit-group` | 未执行 | 待填写 | | SETUP-05 | 创建内部测试 API Key | `POST /api/v1/api-keys` | 至少创建全量 Key、Chat-only Key、受限 Key、禁用验证 Key | 未执行 | 待填写 | | SETUP-06 | 创建内部定价规则集 | `POST /api/v1/pricing/rule-sets` | 规则覆盖 `text_input`、`text_output`、`image`、`image_edit`、`video` | 未执行 | 待填写 | diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index a1cfa8c..609facd 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -530,6 +530,23 @@ export interface GatewayAccessRuleBatchRequest { deleteResources?: GatewayAccessRuleResourceRequest[]; } +export type GatewayAPIKeyRuleDiagnosticReason = 'resource_unavailable' | 'owner_access_revoked' | 'scope_not_allowed'; + +export interface GatewayAPIKeyAccessRuleDiagnostic { + ruleId: string; + resourceType: GatewayAccessResourceType | string; + resourceId: string; + resourceName?: string; + effect: GatewayAccessEffect | string; + effective: boolean; + reason?: GatewayAPIKeyRuleDiagnosticReason | string; +} + +export interface GatewayAPIKeyAssignableModelsResponse { + items: PlatformModel[]; + ruleDiagnostics: GatewayAPIKeyAccessRuleDiagnostic[]; +} + export interface GatewayApiKey { id: string; gatewayTenantId?: string;