fix(access): 统一 API Key 模型权限与列表契约
将全局启用、用户组基线、API Key 专属或排除规则及 scope 按固定顺序求值,避免 Key 越过所属用户组权限,并让运行时候选与模型列表共用同一权限链。 新增 Key 级可分配模型与失效规则诊断接口、OpenAI 兼容 /v1/models 及 rich 列表迁移路径;前端权限弹窗改为按当前 Key 实时加载并支持清理失效规则。 验证:Go 全量测试与 go vet 通过;Web 22 个测试文件共 142 项通过;pnpm lint、pnpm openapi、pnpm build、Compose 配置、gofmt、ShellCheck 和 git diff --check 通过;独立 PostgreSQL 真实配置验收通过。
This commit is contained in:
+244
-26
@@ -5726,6 +5726,7 @@
|
|||||||
"api-keys"
|
"api-keys"
|
||||||
],
|
],
|
||||||
"summary": "列出 API Key 可分配模型",
|
"summary": "列出 API Key 可分配模型",
|
||||||
|
"deprecated": true,
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"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": {
|
"/api/v1/api-keys/{apiKeyID}/disable": {
|
||||||
"patch": {
|
"patch": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -7318,26 +7377,15 @@
|
|||||||
"BearerAuth": []
|
"BearerAuth": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "按当前用户权限返回可用于 Playground 或 API 调用的模型列表。",
|
"description": "兼容期 rich 平台来源明细;新客户端应改用 /api/v1/platform-models,OpenAI 客户端使用 /v1/models。",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
"tags": [
|
"tags": [
|
||||||
"playground"
|
"playground"
|
||||||
],
|
],
|
||||||
"summary": "列出可调用模型",
|
"summary": "列出可调用平台模型(已弃用)",
|
||||||
"parameters": [
|
"deprecated": true,
|
||||||
{
|
|
||||||
"enum": [
|
|
||||||
"canvas_model_node",
|
|
||||||
"desktop"
|
|
||||||
],
|
|
||||||
"type": "string",
|
|
||||||
"description": "模型可选场景;不传时保持原有行为",
|
|
||||||
"name": "usage_scene",
|
|
||||||
"in": "query"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
@@ -7345,12 +7393,6 @@
|
|||||||
"$ref": "#/definitions/httpapi.PlatformModelListResponse"
|
"$ref": "#/definitions/httpapi.PlatformModelListResponse"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
|
||||||
"description": "Bad Request",
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
"401": {
|
||||||
"description": "Unauthorized",
|
"description": "Unauthorized",
|
||||||
"schema": {
|
"schema": {
|
||||||
@@ -7362,12 +7404,6 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/httpapi.ErrorEnvelope"
|
"$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": {
|
"/api/v1/platforms": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"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": {
|
"httpapi.APIKeyListResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"httpapi.PlatformListResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"store.AcceptanceRun": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+160
-18
@@ -47,6 +47,17 @@ definitions:
|
|||||||
username:
|
username:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
httpapi.APIKeyListResponse:
|
||||||
properties:
|
properties:
|
||||||
items:
|
items:
|
||||||
@@ -989,6 +1000,31 @@ definitions:
|
|||||||
$ref: '#/definitions/httpapi.OpenAIImageData'
|
$ref: '#/definitions/httpapi.OpenAIImageData'
|
||||||
type: array
|
type: array
|
||||||
type: object
|
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:
|
httpapi.PlatformListResponse:
|
||||||
properties:
|
properties:
|
||||||
items:
|
items:
|
||||||
@@ -1999,6 +2035,27 @@ definitions:
|
|||||||
userId:
|
userId:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
store.AcceptanceRun:
|
||||||
properties:
|
properties:
|
||||||
apiImageDigest:
|
apiImageDigest:
|
||||||
@@ -7879,6 +7936,43 @@ paths:
|
|||||||
summary: 删除 API Key
|
summary: 删除 API Key
|
||||||
tags:
|
tags:
|
||||||
- api-keys
|
- 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:
|
/api/v1/api-keys/{apiKeyID}/disable:
|
||||||
patch:
|
patch:
|
||||||
description: 禁用当前用户拥有的 API Key,保留记录但不再允许调用。
|
description: 禁用当前用户拥有的 API Key,保留记录但不再允许调用。
|
||||||
@@ -8038,6 +8132,7 @@ paths:
|
|||||||
- api-keys
|
- api-keys
|
||||||
/api/v1/api-keys/assignable-models:
|
/api/v1/api-keys/assignable-models:
|
||||||
get:
|
get:
|
||||||
|
deprecated: true
|
||||||
description: 按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。
|
description: 按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
@@ -8954,15 +9049,8 @@ paths:
|
|||||||
- model-catalog
|
- model-catalog
|
||||||
/api/v1/models:
|
/api/v1/models:
|
||||||
get:
|
get:
|
||||||
description: 按当前用户权限返回可用于 Playground 或 API 调用的模型列表。
|
deprecated: true
|
||||||
parameters:
|
description: 兼容期 rich 平台来源明细;新客户端应改用 /api/v1/platform-models,OpenAI 客户端使用 /v1/models。
|
||||||
- description: 模型可选场景;不传时保持原有行为
|
|
||||||
enum:
|
|
||||||
- canvas_model_node
|
|
||||||
- desktop
|
|
||||||
in: query
|
|
||||||
name: usage_scene
|
|
||||||
type: string
|
|
||||||
produces:
|
produces:
|
||||||
- application/json
|
- application/json
|
||||||
responses:
|
responses:
|
||||||
@@ -8970,10 +9058,6 @@ paths:
|
|||||||
description: OK
|
description: OK
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/httpapi.PlatformModelListResponse'
|
$ref: '#/definitions/httpapi.PlatformModelListResponse'
|
||||||
"400":
|
|
||||||
description: Bad Request
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
|
||||||
"401":
|
"401":
|
||||||
description: Unauthorized
|
description: Unauthorized
|
||||||
schema:
|
schema:
|
||||||
@@ -8982,13 +9066,9 @@ paths:
|
|||||||
description: Internal Server Error
|
description: Internal Server Error
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
||||||
"502":
|
|
||||||
description: Bad Gateway
|
|
||||||
schema:
|
|
||||||
$ref: '#/definitions/httpapi.ErrorEnvelope'
|
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
summary: 列出可调用模型
|
summary: 列出可调用平台模型(已弃用)
|
||||||
tags:
|
tags:
|
||||||
- playground
|
- playground
|
||||||
/api/v1/models/{model}:generateContent:
|
/api/v1/models/{model}:generateContent:
|
||||||
@@ -9195,6 +9275,45 @@ paths:
|
|||||||
summary: 获取 AI Gateway Swagger YAML
|
summary: 获取 AI Gateway Swagger YAML
|
||||||
tags:
|
tags:
|
||||||
- agent-resources
|
- 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:
|
/api/v1/platforms:
|
||||||
get:
|
get:
|
||||||
description: 按当前用户可访问模型过滤平台,仅返回启用且存在可访问模型的平台。
|
description: 按当前用户可访问模型过滤平台,仅返回启用且存在可访问模型的平台。
|
||||||
@@ -10853,6 +10972,29 @@ paths:
|
|||||||
summary: 获取本地上传资源
|
summary: 获取本地上传资源
|
||||||
tags:
|
tags:
|
||||||
- static
|
- 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:
|
schemes:
|
||||||
- http
|
- http
|
||||||
- https
|
- https
|
||||||
|
|||||||
@@ -64,12 +64,14 @@ func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
|
|||||||
// @Tags api-keys
|
// @Tags api-keys
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
|
// @Deprecated
|
||||||
// @Success 200 {object} PlatformModelListResponse
|
// @Success 200 {object} PlatformModelListResponse
|
||||||
// @Failure 401 {object} ErrorEnvelope
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
// @Failure 503 {object} ErrorEnvelope
|
// @Failure 503 {object} ErrorEnvelope
|
||||||
// @Failure 500 {object} ErrorEnvelope
|
// @Failure 500 {object} ErrorEnvelope
|
||||||
// @Router /api/v1/api-keys/assignable-models [get]
|
// @Router /api/v1/api-keys/assignable-models [get]
|
||||||
func (s *Server) listAPIKeyAssignableModels(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) listAPIKeyAssignableModels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Deprecation", "true")
|
||||||
user, _ := auth.UserFromContext(r.Context())
|
user, _ := auth.UserFromContext(r.Context())
|
||||||
models, err := s.store.ListAPIKeyAssignablePlatformModels(r.Context(), user)
|
models, err := s.store.ListAPIKeyAssignablePlatformModels(r.Context(), user)
|
||||||
if err != nil {
|
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)})
|
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
|
// createAccessRule godoc
|
||||||
// @Summary 创建访问规则
|
// @Summary 创建访问规则
|
||||||
// @Description 管理端创建一条访问控制规则。
|
// @Description 管理端创建一条访问控制规则。
|
||||||
@@ -192,7 +229,7 @@ func (s *Server) batchAPIKeyAccessRules(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if errors.Is(err, store.ErrAccessRuleResourceDenied) {
|
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
|
return
|
||||||
}
|
}
|
||||||
s.logger.Error("batch api key access rules failed", "error", err)
|
s.logger.Error("batch api key access rules failed", "error", err)
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type modelAccessFixture struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ModelName string `json:"modelName"`
|
||||||
|
ModelType []string `json:"modelType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelAccessRuleDiagnostic struct {
|
||||||
|
RuleID string `json:"ruleId"`
|
||||||
|
ResourceID string `json:"resourceId"`
|
||||||
|
Effective bool `json:"effective"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type modelAccessAssignableResponse struct {
|
||||||
|
Items []modelAccessFixture `json:"items"`
|
||||||
|
RuleDiagnostics []modelAccessRuleDiagnostic `json:"ruleDiagnostics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
|
||||||
|
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
||||||
|
if databaseURL == "" {
|
||||||
|
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the API key model access integration flow")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
applyMigration(t, ctx, databaseURL)
|
||||||
|
db, err := store.Connect(ctx, databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect store: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
serverCtx, cancelServer := context.WithCancel(ctx)
|
||||||
|
defer cancelServer()
|
||||||
|
server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
|
||||||
|
AppEnv: "test",
|
||||||
|
HTTPAddr: ":0",
|
||||||
|
DatabaseURL: databaseURL,
|
||||||
|
IdentityMode: "hybrid",
|
||||||
|
JWTSecret: "test-secret",
|
||||||
|
CORSAllowedOrigin: "*",
|
||||||
|
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect test pool: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||||
|
register := func(prefix string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
username := prefix + "_" + suffix
|
||||||
|
password := "password123"
|
||||||
|
var registered struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
}
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
|
||||||
|
"username": username,
|
||||||
|
"email": username + "@example.com",
|
||||||
|
"password": password,
|
||||||
|
}, http.StatusCreated, ®istered)
|
||||||
|
return username, password
|
||||||
|
}
|
||||||
|
login := func(username string, password string) string {
|
||||||
|
t.Helper()
|
||||||
|
var response struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
}
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||||||
|
"account": username, "password": password,
|
||||||
|
}, http.StatusOK, &response)
|
||||||
|
return response.AccessToken
|
||||||
|
}
|
||||||
|
adminName, adminPassword := register("layered_admin")
|
||||||
|
userBName, userBPassword := register("layered_user_b")
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, adminName); err != nil {
|
||||||
|
t.Fatalf("promote admin: %v", err)
|
||||||
|
}
|
||||||
|
adminToken := login(adminName, adminPassword)
|
||||||
|
|
||||||
|
createGroup := func(key string) string {
|
||||||
|
t.Helper()
|
||||||
|
var group struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/admin/user-groups", adminToken, map[string]any{
|
||||||
|
"groupKey": key + "-" + suffix,
|
||||||
|
"name": key,
|
||||||
|
"source": "gateway",
|
||||||
|
"status": "active",
|
||||||
|
}, http.StatusCreated, &group)
|
||||||
|
return group.ID
|
||||||
|
}
|
||||||
|
groupAID := createGroup("layered-group-a")
|
||||||
|
groupBID := createGroup("layered-group-b")
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE gateway_users SET default_user_group_id = $1::uuid WHERE username = $2`, groupAID, adminName); err != nil {
|
||||||
|
t.Fatalf("assign group A: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `UPDATE gateway_users SET default_user_group_id = $1::uuid WHERE username = $2`, groupBID, userBName); err != nil {
|
||||||
|
t.Fatalf("assign group B: %v", err)
|
||||||
|
}
|
||||||
|
adminToken = login(adminName, adminPassword)
|
||||||
|
userBToken := login(userBName, userBPassword)
|
||||||
|
|
||||||
|
createKey := func(token string, name string, scopes []string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
var created struct {
|
||||||
|
APIKey struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"apiKey"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
}
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", token, map[string]any{
|
||||||
|
"name": name, "scopes": scopes,
|
||||||
|
}, http.StatusCreated, &created)
|
||||||
|
return created.APIKey.ID, created.Secret
|
||||||
|
}
|
||||||
|
keyAID, keyASecret := createKey(adminToken, "group A image key", []string{"image"})
|
||||||
|
keyAInheritedID, keyAInheritedSecret := createKey(adminToken, "group A inherited image key", []string{"image"})
|
||||||
|
keyMultiID, keyMultiSecret := createKey(adminToken, "group A multi-source image key", []string{"image"})
|
||||||
|
keyScopeID, _ := createKey(adminToken, "group A narrowed scope key", []string{"all"})
|
||||||
|
keyLegacyID, _ := createKey(adminToken, "group A narrowed group key", []string{"all"})
|
||||||
|
keyBID, keyBSecret := createKey(userBToken, "group B chat key", []string{"chat"})
|
||||||
|
keyBImageID, keyBImageSecret := createKey(userBToken, "group B image key", []string{"image"})
|
||||||
|
|
||||||
|
createSimulationBaseModel(t, server.URL, adminToken, "layered-text-"+suffix, []string{"text_generate"})
|
||||||
|
createSimulationBaseModel(t, server.URL, adminToken, "layered-image-"+suffix, []string{"image_generate"})
|
||||||
|
createSimulationBaseModel(t, server.URL, adminToken, "layered-multi-"+suffix, []string{"text_generate", "image_generate"})
|
||||||
|
createSimulationBaseModel(t, server.URL, adminToken, "layered-key-deny-image-"+suffix, []string{"image_generate"})
|
||||||
|
createSimulationBaseModel(t, server.URL, adminToken, "layered-group-b-image-"+suffix, []string{"image_generate"})
|
||||||
|
createSimulationBaseModel(t, server.URL, adminToken, "layered-scope-text-"+suffix, []string{"text_generate"})
|
||||||
|
createSimulationBaseModel(t, server.URL, adminToken, "layered-legacy-text-"+suffix, []string{"text_generate"})
|
||||||
|
|
||||||
|
createPlatform := func(key string, status string) string {
|
||||||
|
t.Helper()
|
||||||
|
var platform struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", adminToken, map[string]any{
|
||||||
|
"provider": "openai",
|
||||||
|
"platformKey": key + "-" + suffix,
|
||||||
|
"name": key,
|
||||||
|
"baseUrl": "https://api.openai.com/v1",
|
||||||
|
"authType": "bearer",
|
||||||
|
"credentials": map[string]any{"mode": "simulation"},
|
||||||
|
"config": map[string]any{"testMode": true},
|
||||||
|
"status": status,
|
||||||
|
}, http.StatusCreated, &platform)
|
||||||
|
return platform.ID
|
||||||
|
}
|
||||||
|
enabledPlatformID := createPlatform("layered-enabled", "enabled")
|
||||||
|
secondPlatformID := createPlatform("layered-second", "enabled")
|
||||||
|
disabledPlatformID := createPlatform("layered-disabled", "disabled")
|
||||||
|
|
||||||
|
createModel := func(platformID string, modelName string, modelTypes []string) modelAccessFixture {
|
||||||
|
t.Helper()
|
||||||
|
var model modelAccessFixture
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platformID+"/models", adminToken, map[string]any{
|
||||||
|
"canonicalModelKey": "openai:" + modelName,
|
||||||
|
"modelName": modelName,
|
||||||
|
"modelAlias": modelName,
|
||||||
|
"modelType": modelTypes,
|
||||||
|
"displayName": modelName,
|
||||||
|
}, http.StatusCreated, &model)
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
textName := "layered-text-" + suffix
|
||||||
|
imageName := "layered-image-" + suffix
|
||||||
|
multiName := "layered-multi-" + suffix
|
||||||
|
keyDenyImageName := "layered-key-deny-image-" + suffix
|
||||||
|
groupBImageName := "layered-group-b-image-" + suffix
|
||||||
|
scopeTextName := "layered-scope-text-" + suffix
|
||||||
|
legacyTextName := "layered-legacy-text-" + suffix
|
||||||
|
textModel := createModel(enabledPlatformID, textName, []string{"text_generate"})
|
||||||
|
imageModel := createModel(enabledPlatformID, imageName, []string{"image_generate"})
|
||||||
|
multiModel := createModel(enabledPlatformID, multiName, []string{"text_generate", "image_generate"})
|
||||||
|
multiSecondModel := createModel(secondPlatformID, multiName, []string{"text_generate", "image_generate"})
|
||||||
|
disabledImageModel := createModel(disabledPlatformID, imageName, []string{"image_generate"})
|
||||||
|
keyDenyImageModel := createModel(enabledPlatformID, keyDenyImageName, []string{"image_generate"})
|
||||||
|
groupBImageModel := createModel(enabledPlatformID, groupBImageName, []string{"image_generate"})
|
||||||
|
scopeTextModel := createModel(enabledPlatformID, scopeTextName, []string{"text_generate"})
|
||||||
|
legacyTextModel := createModel(enabledPlatformID, legacyTextName, []string{"text_generate"})
|
||||||
|
|
||||||
|
createRule := func(subjectType string, subjectID string, resourceID string, effect string) {
|
||||||
|
t.Helper()
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/admin/access-rules", adminToken, map[string]any{
|
||||||
|
"subjectType": subjectType, "subjectId": subjectID,
|
||||||
|
"resourceType": "platform_model", "resourceId": resourceID,
|
||||||
|
"effect": effect, "priority": 10, "status": "active",
|
||||||
|
}, http.StatusCreated, nil)
|
||||||
|
}
|
||||||
|
batchKeyRule := func(ownerToken string, keyID string, resourceID string, effect string, expectedStatus int) {
|
||||||
|
t.Helper()
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys/access-rules/batch", ownerToken, map[string]any{
|
||||||
|
"subjectType": "api_key", "subjectId": keyID, "effect": effect,
|
||||||
|
"upsertResources": []map[string]any{{"resourceType": "platform_model", "resourceId": resourceID, "status": "active"}},
|
||||||
|
"deleteResources": []map[string]any{},
|
||||||
|
}, expectedStatus, nil)
|
||||||
|
}
|
||||||
|
// Create valid legacy rules first, then narrow the group or scope. The rows
|
||||||
|
// must remain for diagnostics while becoming ineffective globally.
|
||||||
|
batchKeyRule(adminToken, keyLegacyID, legacyTextModel.ID, "allow", http.StatusOK)
|
||||||
|
batchKeyRule(adminToken, keyScopeID, scopeTextModel.ID, "allow", http.StatusOK)
|
||||||
|
createRule("user_group", groupAID, imageModel.ID, "allow")
|
||||||
|
createRule("user_group", groupAID, textModel.ID, "deny")
|
||||||
|
createRule("user_group", groupAID, legacyTextModel.ID, "deny")
|
||||||
|
createRule("user_group", groupBID, groupBImageModel.ID, "allow")
|
||||||
|
doJSON(t, server.URL, http.MethodPatch, "/api/v1/api-keys/"+keyScopeID+"/scopes", adminToken, map[string]any{
|
||||||
|
"scopes": []string{"image"},
|
||||||
|
}, http.StatusOK, nil)
|
||||||
|
// Admin-created historical rules can refer to resources that are no longer
|
||||||
|
// assignable. They are retained but cannot expand or reserve the resource.
|
||||||
|
createRule("api_key", keyAID, textModel.ID, "allow")
|
||||||
|
createRule("api_key", keyScopeID, disabledImageModel.ID, "allow")
|
||||||
|
batchKeyRule(adminToken, keyAID, imageModel.ID, "allow", http.StatusOK)
|
||||||
|
batchKeyRule(adminToken, keyAID, keyDenyImageModel.ID, "deny", http.StatusOK)
|
||||||
|
|
||||||
|
loadAssignable := func(token string, keyID string) modelAccessAssignableResponse {
|
||||||
|
t.Helper()
|
||||||
|
var response modelAccessAssignableResponse
|
||||||
|
doJSON(t, server.URL, http.MethodGet, "/api/v1/api-keys/"+keyID+"/assignable-models", token, nil, http.StatusOK, &response)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
assignable := loadAssignable(adminToken, keyAID)
|
||||||
|
if containsModelID(assignable.Items, textModel.ID) {
|
||||||
|
t.Fatalf("group-denied text model leaked into key candidates: %+v", assignable.Items)
|
||||||
|
}
|
||||||
|
if !containsModelID(assignable.Items, imageModel.ID) || !containsModelID(assignable.Items, multiModel.ID) {
|
||||||
|
t.Fatalf("image-scope key candidates missing allowed models: %+v", assignable.Items)
|
||||||
|
}
|
||||||
|
if !containsModelID(assignable.Items, keyDenyImageModel.ID) {
|
||||||
|
t.Fatalf("key rules incorrectly removed a model from the assignable set: %+v", assignable.Items)
|
||||||
|
}
|
||||||
|
if containsModelID(assignable.Items, disabledImageModel.ID) || containsModelID(assignable.Items, groupBImageModel.ID) {
|
||||||
|
t.Fatalf("disabled or another-group-exclusive source leaked into assignable models: %+v", assignable.Items)
|
||||||
|
}
|
||||||
|
for _, model := range assignable.Items {
|
||||||
|
if model.ID == multiModel.ID && (len(model.ModelType) != 1 || model.ModelType[0] != "image_generate") {
|
||||||
|
t.Fatalf("multi-capability model was not scope-pruned: %+v", model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foundRevoked := false
|
||||||
|
for _, diagnostic := range assignable.RuleDiagnostics {
|
||||||
|
if !diagnostic.Effective && diagnostic.Reason == "owner_access_revoked" {
|
||||||
|
foundRevoked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundRevoked {
|
||||||
|
t.Fatalf("stale key rule diagnostic missing: %+v", assignable.RuleDiagnostics)
|
||||||
|
}
|
||||||
|
scopeDiagnostics := loadAssignable(adminToken, keyScopeID).RuleDiagnostics
|
||||||
|
if !containsDiagnosticReason(scopeDiagnostics, "scope_not_allowed") || !containsDiagnosticReason(scopeDiagnostics, "resource_unavailable") {
|
||||||
|
t.Fatalf("scope/resource diagnostics missing after narrowing: %+v", scopeDiagnostics)
|
||||||
|
}
|
||||||
|
legacyDiagnostics := loadAssignable(adminToken, keyLegacyID).RuleDiagnostics
|
||||||
|
if !containsDiagnosticReason(legacyDiagnostics, "owner_access_revoked") {
|
||||||
|
t.Fatalf("group-narrowed rule diagnostic missing: %+v", legacyDiagnostics)
|
||||||
|
}
|
||||||
|
t.Logf("候选与诊断:keyA=%d models, diagnostics=%d; scope=%v; legacy=%v", len(assignable.Items), len(assignable.RuleDiagnostics), diagnosticReasons(scopeDiagnostics), diagnosticReasons(legacyDiagnostics))
|
||||||
|
|
||||||
|
// The regular key rule endpoint must reject the same group-denied resource.
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys/access-rules/batch", adminToken, map[string]any{
|
||||||
|
"subjectType": "api_key", "subjectId": keyAID, "effect": "allow",
|
||||||
|
"upsertResources": []map[string]any{{"resourceType": "platform_model", "resourceId": textModel.ID, "status": "active"}},
|
||||||
|
"deleteResources": []map[string]any{},
|
||||||
|
}, http.StatusForbidden, nil)
|
||||||
|
|
||||||
|
var platformModels struct {
|
||||||
|
Items []modelAccessFixture `json:"items"`
|
||||||
|
}
|
||||||
|
doJSON(t, server.URL, http.MethodGet, "/api/v1/platform-models", keyASecret, nil, http.StatusOK, &platformModels)
|
||||||
|
if containsModelID(platformModels.Items, textModel.ID) {
|
||||||
|
t.Fatalf("group-denied text model leaked into key model list: %+v", platformModels.Items)
|
||||||
|
}
|
||||||
|
if !containsModelID(platformModels.Items, imageModel.ID) || containsModelID(platformModels.Items, keyDenyImageModel.ID) || containsModelID(platformModels.Items, disabledImageModel.ID) {
|
||||||
|
t.Fatalf("key allow/deny or global availability was not reflected in rich list: %+v", platformModels.Items)
|
||||||
|
}
|
||||||
|
if !containsModelID(loadAssignable(adminToken, keyAInheritedID).Items, imageModel.ID) {
|
||||||
|
t.Fatalf("assignable list must ignore another key's exclusive rule")
|
||||||
|
}
|
||||||
|
var inheritedPlatformModels struct {
|
||||||
|
Items []modelAccessFixture `json:"items"`
|
||||||
|
}
|
||||||
|
doJSON(t, server.URL, http.MethodGet, "/api/v1/platform-models", keyAInheritedSecret, nil, http.StatusOK, &inheritedPlatformModels)
|
||||||
|
if containsModelID(inheritedPlatformModels.Items, imageModel.ID) || !containsModelID(inheritedPlatformModels.Items, keyDenyImageModel.ID) {
|
||||||
|
t.Fatalf("key exclusive/deny isolation mismatch for sibling key: %+v", inheritedPlatformModels.Items)
|
||||||
|
}
|
||||||
|
groupBAssignable := loadAssignable(userBToken, keyBImageID)
|
||||||
|
if containsModelID(groupBAssignable.Items, imageModel.ID) || !containsModelID(groupBAssignable.Items, groupBImageModel.ID) {
|
||||||
|
t.Fatalf("group exclusive rules were not applied to key candidates: %+v", groupBAssignable.Items)
|
||||||
|
}
|
||||||
|
var groupBChatModels struct {
|
||||||
|
Items []modelAccessFixture `json:"items"`
|
||||||
|
}
|
||||||
|
doJSON(t, server.URL, http.MethodGet, "/api/v1/platform-models", keyBSecret, nil, http.StatusOK, &groupBChatModels)
|
||||||
|
for _, model := range []modelAccessFixture{textModel, scopeTextModel, legacyTextModel} {
|
||||||
|
if !containsModelID(groupBChatModels.Items, model.ID) {
|
||||||
|
t.Fatalf("stale key/group-A rule blocked group B model %s: %+v", model.ID, groupBChatModels.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("用户组与 KEY 隔离:keyA rich=%d, sibling rich=%d, groupB image candidates=%d", len(platformModels.Items), len(inheritedPlatformModels.Items), len(groupBAssignable.Items))
|
||||||
|
|
||||||
|
var openAIList struct {
|
||||||
|
Object string `json:"object"`
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
doJSON(t, server.URL, http.MethodGet, "/v1/models", keyASecret, nil, http.StatusOK, &openAIList)
|
||||||
|
if openAIList.Object != "list" || countOpenAIModel(openAIList.Data, multiName) != 1 {
|
||||||
|
t.Fatalf("openai model list is not deduplicated: %+v", openAIList)
|
||||||
|
}
|
||||||
|
legacyHeaders := doJSONWithHeaders(t, server.URL, http.MethodGet, "/api/v1/models", keyASecret, nil, nil, http.StatusOK, &platformModels)
|
||||||
|
if legacyHeaders.Get("Deprecation") != "true" || !strings.Contains(legacyHeaders.Get("Link"), "/api/v1/platform-models") {
|
||||||
|
t.Fatalf("legacy model list deprecation headers missing: %+v", legacyHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two sources with the same logical name collapse to one OpenAI model. A
|
||||||
|
// source-level deny removes only that source until the final source is gone.
|
||||||
|
var multiRich struct {
|
||||||
|
Items []modelAccessFixture `json:"items"`
|
||||||
|
}
|
||||||
|
doJSON(t, server.URL, http.MethodGet, "/api/v1/platform-models", keyMultiSecret, nil, http.StatusOK, &multiRich)
|
||||||
|
if countRichModel(multiRich.Items, multiName) != 2 {
|
||||||
|
t.Fatalf("expected two initial rich sources for %s: %+v", multiName, multiRich.Items)
|
||||||
|
}
|
||||||
|
batchKeyRule(adminToken, keyMultiID, multiModel.ID, "deny", http.StatusOK)
|
||||||
|
doJSON(t, server.URL, http.MethodGet, "/api/v1/platform-models", keyMultiSecret, nil, http.StatusOK, &multiRich)
|
||||||
|
if countRichModel(multiRich.Items, multiName) != 1 || containsModelID(multiRich.Items, multiModel.ID) || !containsModelID(multiRich.Items, multiSecondModel.ID) {
|
||||||
|
t.Fatalf("first source deny did not leave exactly the second source: %+v", multiRich.Items)
|
||||||
|
}
|
||||||
|
var multiOpenAI struct {
|
||||||
|
Object string `json:"object"`
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
doJSON(t, server.URL, http.MethodGet, "/v1/models", keyMultiSecret, nil, http.StatusOK, &multiOpenAI)
|
||||||
|
if countOpenAIModel(multiOpenAI.Data, multiName) != 1 {
|
||||||
|
t.Fatalf("logical model disappeared while one source remained: %+v", multiOpenAI.Data)
|
||||||
|
}
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyMultiSecret, map[string]any{
|
||||||
|
"model": multiName, "prompt": "layered multi source", "runMode": "simulation", "simulation": true,
|
||||||
|
}, http.StatusOK, nil)
|
||||||
|
batchKeyRule(adminToken, keyMultiID, multiSecondModel.ID, "deny", http.StatusOK)
|
||||||
|
doJSON(t, server.URL, http.MethodGet, "/v1/models", keyMultiSecret, nil, http.StatusOK, &multiOpenAI)
|
||||||
|
if countOpenAIModel(multiOpenAI.Data, multiName) != 0 {
|
||||||
|
t.Fatalf("logical model remained after all sources were denied: %+v", multiOpenAI.Data)
|
||||||
|
}
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyMultiSecret, map[string]any{
|
||||||
|
"model": multiName, "prompt": "no sources", "runMode": "simulation", "simulation": true,
|
||||||
|
}, http.StatusNotFound, nil)
|
||||||
|
t.Logf("多来源模型:初始来源=2,排除一个后 OpenAI 逻辑模型=1,全部排除后=0;调用状态=200/404")
|
||||||
|
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyASecret, map[string]any{
|
||||||
|
"model": imageName, "prompt": "exclusive key", "runMode": "simulation", "simulation": true,
|
||||||
|
}, http.StatusOK, nil)
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyAInheritedSecret, map[string]any{
|
||||||
|
"model": imageName, "prompt": "sibling key", "runMode": "simulation", "simulation": true,
|
||||||
|
}, http.StatusNotFound, nil)
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyASecret, map[string]any{
|
||||||
|
"model": keyDenyImageName, "prompt": "key deny", "runMode": "simulation", "simulation": true,
|
||||||
|
}, http.StatusNotFound, nil)
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyBImageSecret, map[string]any{
|
||||||
|
"model": groupBImageName, "prompt": "group B exclusive", "runMode": "simulation", "simulation": true,
|
||||||
|
}, http.StatusOK, nil)
|
||||||
|
t.Logf("实际调用:KEY 专属 200,兄弟 KEY 404,KEY 排除 404,组 B 专属 200")
|
||||||
|
|
||||||
|
// Group B can still use text: stale allows whose owners lost group access or
|
||||||
|
// scope are removed from the global exclusive set.
|
||||||
|
if !containsModelID(loadAssignable(userBToken, keyBID).Items, textModel.ID) {
|
||||||
|
t.Fatalf("group B chat key candidate list lost the group-A denied model")
|
||||||
|
}
|
||||||
|
for _, modelName := range []string{textName, scopeTextName, legacyTextName} {
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", keyBSecret, map[string]any{
|
||||||
|
"model": modelName, "messages": []map[string]any{{"role": "user", "content": "layered access"}},
|
||||||
|
"runMode": "simulation", "simulation": true,
|
||||||
|
}, http.StatusOK, nil)
|
||||||
|
}
|
||||||
|
t.Logf("失效旧规则:组 B 对 3 个文本模型的 simulation 调用均为 200")
|
||||||
|
|
||||||
|
// The image-only key is rejected before candidate selection for chat.
|
||||||
|
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", keyASecret, map[string]any{
|
||||||
|
"model": multiName, "messages": []map[string]any{{"role": "user", "content": "scope denial"}},
|
||||||
|
}, http.StatusForbidden, nil)
|
||||||
|
t.Logf("scope:image KEY 的多能力候选仅保留 image_generate;图像调用 200,文本调用 403")
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsModelID(items []modelAccessFixture, id string) bool {
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ID == id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func countRichModel(items []modelAccessFixture, modelName string) int {
|
||||||
|
count := 0
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ModelName == modelName {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsDiagnosticReason(items []modelAccessRuleDiagnostic, reason string) bool {
|
||||||
|
for _, item := range items {
|
||||||
|
if !item.Effective && item.Reason == reason {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnosticReasons(items []modelAccessRuleDiagnostic) []string {
|
||||||
|
reasons := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if !item.Effective {
|
||||||
|
reasons = append(reasons, item.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reasons
|
||||||
|
}
|
||||||
|
|
||||||
|
func countOpenAIModel(items []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}, id string) int {
|
||||||
|
count := 0
|
||||||
|
for _, item := range items {
|
||||||
|
if item.ID == id {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
"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/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/netproxy"
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
"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 401 {object} ErrorEnvelope
|
||||||
// @Failure 502 {object} ErrorEnvelope
|
// @Failure 502 {object} ErrorEnvelope
|
||||||
// @Failure 500 {object} ErrorEnvelope
|
// @Failure 500 {object} ErrorEnvelope
|
||||||
// @Router /api/v1/models [get]
|
// @Router /api/v1/platform-models [get]
|
||||||
// @Router /api/v1/playground/models [get]
|
// @Router /api/v1/playground/models [get]
|
||||||
func (s *Server) listPlayableModels(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) listPlayableModels(w http.ResponseWriter, r *http.Request) {
|
||||||
usageScene, err := parseModelUsageSceneQuery(r.URL.Query())
|
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 {
|
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
|
return true
|
||||||
}
|
}
|
||||||
required := scopeForTaskKind(kind)
|
return modelaccess.ScopeAllowsTask(user.APIKeyScopes, 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestModelName(body map[string]any) string {
|
func requestModelName(body map[string]any) string {
|
||||||
@@ -1687,33 +1657,6 @@ func modelNameFromValue(value any) string {
|
|||||||
return ""
|
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 {
|
func statusFromRunError(err error) int {
|
||||||
switch {
|
switch {
|
||||||
case clients.ErrorCode(err) == "binary_result_expired":
|
case clients.ErrorCode(err) == "binary_result_expired":
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listLegacyPlayableModels godoc
|
||||||
|
// @Summary 列出可调用平台模型(已弃用)
|
||||||
|
// @Description 兼容期 rich 平台来源明细;新客户端应改用 /api/v1/platform-models,OpenAI 客户端使用 /v1/models。
|
||||||
|
// @Tags playground
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Deprecated
|
||||||
|
// @Success 200 {object} PlatformModelListResponse
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 500 {object} ErrorEnvelope
|
||||||
|
// @Router /api/v1/models [get]
|
||||||
|
func (s *Server) listLegacyPlayableModels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Deprecation", "true")
|
||||||
|
w.Header().Set("Link", "</api/v1/platform-models>; rel=\"successor-version\"")
|
||||||
|
s.listPlayableModels(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listOpenAIModels godoc
|
||||||
|
// @Summary 列出 OpenAI 兼容模型
|
||||||
|
// @Description 按当前身份、API Key 访问规则及 scope 返回去重后的逻辑模型列表。
|
||||||
|
// @Tags openai-compatible
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {object} OpenAIModelListResponse
|
||||||
|
// @Failure 401 {object} ErrorEnvelope
|
||||||
|
// @Failure 500 {object} ErrorEnvelope
|
||||||
|
// @Router /v1/models [get]
|
||||||
|
func (s *Server) listOpenAIModels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, _ := auth.UserFromContext(r.Context())
|
||||||
|
models, err := s.store.ListAccessiblePlatformModels(r.Context(), user)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("list openai models failed", "error", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "list models failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
byID := map[string]OpenAIModel{}
|
||||||
|
for _, model := range models {
|
||||||
|
id := model.ModelName
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
created := model.CreatedAt.Unix()
|
||||||
|
current, exists := byID[id]
|
||||||
|
if !exists || created < current.Created {
|
||||||
|
byID[id] = OpenAIModel{ID: id, Object: "model", Created: created, OwnedBy: "easyai"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data := make([]OpenAIModel, 0, len(byID))
|
||||||
|
for _, model := range byID {
|
||||||
|
data = append(data, model)
|
||||||
|
}
|
||||||
|
sort.Slice(data, func(i, j int) bool { return data[i].ID < data[j].ID })
|
||||||
|
writeJSON(w, http.StatusOK, OpenAIModelListResponse{Object: "list", Data: data})
|
||||||
|
}
|
||||||
@@ -107,6 +107,23 @@ type PlatformModelListResponse struct {
|
|||||||
Items []store.PlatformModel `json:"items"`
|
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 {
|
type CatalogProviderListResponse struct {
|
||||||
Items []store.CatalogProvider `json:"items"`
|
Items []store.CatalogProvider `json:"items"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ func TestOpenAPIPublicRoutesUseCanonicalV1Prefix(t *testing.T) {
|
|||||||
t.Errorf("legacy public route must not be advertised in OpenAPI: %s", route)
|
t.Errorf("legacy public route must not be advertised in OpenAPI: %s", route)
|
||||||
}
|
}
|
||||||
for _, prefix := range legacyPrefixes {
|
for _, prefix := range legacyPrefixes {
|
||||||
|
if route == "/v1/models" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if strings.HasPrefix(route, prefix) {
|
if strings.HasPrefix(route, prefix) {
|
||||||
t.Errorf("legacy public route must not be advertised in OpenAPI: %s", route)
|
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/generations",
|
||||||
"/api/v1/videos/upscales",
|
"/api/v1/videos/upscales",
|
||||||
"/api/v1/pricing/estimate",
|
"/api/v1/pricing/estimate",
|
||||||
|
"/v1/models",
|
||||||
"/api/workspace/token-usage/daily",
|
"/api/workspace/token-usage/daily",
|
||||||
"/api/v1/models/{model}:generateContent",
|
"/api/v1/models/{model}:generateContent",
|
||||||
"/api/v1/videos/omni-video",
|
"/api/v1/videos/omni-video",
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ func NewServerWithStores(
|
|||||||
mux.Handle("GET /api/v1/api-keys/access-rules", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules)))
|
mux.Handle("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("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/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}/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("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)))
|
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/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/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/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 /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/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("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()))
|
mux.Handle("POST /api/v1/chat/completions", server.requireProtocolUser(clients.ProtocolOpenAIChatCompletions, server.createAPIV1ChatCompletions()))
|
||||||
|
|||||||
@@ -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)), "-", "_")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -244,7 +244,7 @@ SELECT EXISTS (
|
|||||||
if !exists {
|
if !exists {
|
||||||
return nil, pgx.ErrNoRows
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := s.BatchAccessRules(ctx, input); err != nil {
|
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) {
|
func (s *Store) filterCandidatesByAccessRules(ctx context.Context, user *auth.User, candidates []RuntimeModelCandidate) ([]RuntimeModelCandidate, error) {
|
||||||
if len(candidates) == 0 {
|
return s.filterRuntimeCandidatesByLayeredAccess(ctx, user, candidates)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
models, err := s.ListModels(ctx)
|
models, _, err := s.enabledPlatformModels(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
platforms, err := s.ListPlatforms(ctx)
|
if excludedSubjectTypes["api_key"] {
|
||||||
if err != nil {
|
return s.filterPlatformModelsByBaselineAccess(ctx, accessUser, models)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
enabledPlatforms := map[string]bool{}
|
return s.filterPlatformModelsByLayeredAccess(ctx, accessUser, models)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
resources = dedupeAccessRuleResources(resources)
|
||||||
if len(resources) == 0 {
|
if len(resources) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
allowed, err := s.accessibleAccessRuleResources(ctx, user)
|
allowed, err := s.accessibleAccessRuleResources(ctx, user, apiKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -342,8 +304,8 @@ func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) accessibleAccessRuleResources(ctx context.Context, user *auth.User) (map[string]bool, error) {
|
func (s *Store) accessibleAccessRuleResources(ctx context.Context, user *auth.User, apiKeyID string) (map[string]bool, error) {
|
||||||
models, err := s.ListAPIKeyAssignablePlatformModels(ctx, user)
|
models, _, err := s.ListAPIKeyAssignablePlatformModelsForKey(ctx, user, apiKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -368,25 +330,28 @@ func (s *Store) resolveCurrentAccessUser(ctx context.Context, user *auth.User) (
|
|||||||
}
|
}
|
||||||
next := *user
|
next := *user
|
||||||
var userGroupID string
|
var userGroupID string
|
||||||
|
var userGroupKey string
|
||||||
var err error
|
var err error
|
||||||
if strings.TrimSpace(user.APIKeyID) != "" {
|
if strings.TrimSpace(user.APIKeyID) != "" {
|
||||||
err = s.pool.QueryRow(ctx, `
|
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
|
FROM gateway_users u
|
||||||
JOIN gateway_api_keys k ON k.gateway_user_id = u.id
|
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
|
WHERE u.id = $1::uuid
|
||||||
AND k.id = $2::uuid
|
AND k.id = $2::uuid
|
||||||
AND u.status = 'active'
|
AND u.status = 'active'
|
||||||
AND u.deleted_at IS NULL
|
AND u.deleted_at IS NULL
|
||||||
AND k.status = 'active'
|
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 {
|
} else {
|
||||||
err = s.pool.QueryRow(ctx, `
|
err = s.pool.QueryRow(ctx, `
|
||||||
SELECT COALESCE(default_user_group_id::text, '')
|
SELECT COALESCE(u.default_user_group_id::text, ''), COALESCE(g.group_key, '')
|
||||||
FROM gateway_users
|
FROM gateway_users u
|
||||||
WHERE id = $1::uuid
|
LEFT JOIN gateway_user_groups g ON g.id = u.default_user_group_id
|
||||||
AND status = 'active'
|
WHERE u.id = $1::uuid
|
||||||
AND deleted_at IS NULL`, gatewayUserID).Scan(&userGroupID)
|
AND u.status = 'active'
|
||||||
|
AND u.deleted_at IS NULL`, gatewayUserID).Scan(&userGroupID, &userGroupKey)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -395,6 +360,11 @@ WHERE id = $1::uuid
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
next.UserGroupID = userGroupID
|
next.UserGroupID = userGroupID
|
||||||
|
next.UserGroupKey = userGroupKey
|
||||||
|
next.UserGroupKeys = nil
|
||||||
|
if userGroupKey != "" {
|
||||||
|
next.UserGroupKeys = []string{userGroupKey}
|
||||||
|
}
|
||||||
return &next, nil
|
return &next, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-22
@@ -73,7 +73,6 @@ import {
|
|||||||
listAdminTasks,
|
listAdminTasks,
|
||||||
listAuditLogs,
|
listAuditLogs,
|
||||||
listApiKeyAccessRules,
|
listApiKeyAccessRules,
|
||||||
listApiKeyAssignableModels,
|
|
||||||
listApiKeys,
|
listApiKeys,
|
||||||
listBaseModels,
|
listBaseModels,
|
||||||
listCatalogProviders,
|
listCatalogProviders,
|
||||||
@@ -186,7 +185,6 @@ type DataKey =
|
|||||||
| 'publicCatalog'
|
| 'publicCatalog'
|
||||||
| 'playgroundApiKeys'
|
| 'playgroundApiKeys'
|
||||||
| 'playgroundModels'
|
| 'playgroundModels'
|
||||||
| 'apiKeyPolicyModels'
|
|
||||||
| 'modelCatalog'
|
| 'modelCatalog'
|
||||||
| 'networkProxyConfig'
|
| 'networkProxyConfig'
|
||||||
| 'clientCustomizationSettings'
|
| 'clientCustomizationSettings'
|
||||||
@@ -241,7 +239,6 @@ export function App() {
|
|||||||
summary: { modelCount: 0, sourceCount: 0 },
|
summary: { modelCount: 0, sourceCount: 0 },
|
||||||
});
|
});
|
||||||
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
|
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
|
||||||
const [apiKeyPolicyModels, setApiKeyPolicyModels] = useState<PlatformModel[]>([]);
|
|
||||||
const [networkProxyConfig, setNetworkProxyConfig] = useState<GatewayNetworkProxyConfig | null>(null);
|
const [networkProxyConfig, setNetworkProxyConfig] = useState<GatewayNetworkProxyConfig | null>(null);
|
||||||
const [clientCustomizationSettings, setClientCustomizationSettings] = useState<ClientCustomizationSettings | null>(null);
|
const [clientCustomizationSettings, setClientCustomizationSettings] = useState<ClientCustomizationSettings | null>(null);
|
||||||
const [fileStorageChannels, setFileStorageChannels] = useState<FileStorageChannel[]>([]);
|
const [fileStorageChannels, setFileStorageChannels] = useState<FileStorageChannel[]>([]);
|
||||||
@@ -563,9 +560,6 @@ export function App() {
|
|||||||
case 'playgroundModels':
|
case 'playgroundModels':
|
||||||
setPlaygroundModels((await listPlayableModels(nextToken)).items);
|
setPlaygroundModels((await listPlayableModels(nextToken)).items);
|
||||||
return;
|
return;
|
||||||
case 'apiKeyPolicyModels':
|
|
||||||
setApiKeyPolicyModels((await listApiKeyAssignableModels(nextToken)).items);
|
|
||||||
return;
|
|
||||||
case 'playgroundApiKeys': {
|
case 'playgroundApiKeys': {
|
||||||
const response = await listPlayableApiKeys(nextToken);
|
const response = await listPlayableApiKeys(nextToken);
|
||||||
setApiKeys(response.items);
|
setApiKeys(response.items);
|
||||||
@@ -717,7 +711,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const response = await createApiKey(token, {
|
const response = await createApiKey(token, {
|
||||||
name: apiKeyForm.name,
|
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,
|
expiresAt: apiKeyForm.expiresAt ? new Date(apiKeyForm.expiresAt).toISOString() : undefined,
|
||||||
});
|
});
|
||||||
setApiKeySecret(response.secret);
|
setApiKeySecret(response.secret);
|
||||||
@@ -750,7 +744,7 @@ export function App() {
|
|||||||
const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings);
|
const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings);
|
||||||
setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]);
|
setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]);
|
||||||
setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]);
|
setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]);
|
||||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage(input.platformId
|
setCoreMessage(input.platformId
|
||||||
? `平台已更新,当前绑定 ${input.models.length} 个模型。`
|
? `平台已更新,当前绑定 ${input.models.length} 个模型。`
|
||||||
@@ -770,7 +764,7 @@ export function App() {
|
|||||||
const updated = await updatePlatform(token, platform.id, input);
|
const updated = await updatePlatform(token, platform.id, input);
|
||||||
const platformForState = withCredentialPreviewFallback(updated, input, platform);
|
const platformForState = withCredentialPreviewFallback(updated, input, platform);
|
||||||
setPlatforms((current) => current.map((item) => item.id === platform.id ? platformForState : item));
|
setPlatforms((current) => current.map((item) => item.id === platform.id ? platformForState : item));
|
||||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage(status === 'enabled' ? '平台已启用。' : '平台已禁用。');
|
setCoreMessage(status === 'enabled' ? '平台已启用。' : '平台已禁用。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -802,7 +796,7 @@ export function App() {
|
|||||||
platformPriority: state.priority,
|
platformPriority: state.priority,
|
||||||
}
|
}
|
||||||
: status));
|
: status));
|
||||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels', 'apiKeyPolicyModels');
|
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage(input.reset ? '平台动态优先级已重置。' : '平台动态优先级已更新。');
|
setCoreMessage(input.reset ? '平台动态优先级已重置。' : '平台动态优先级已更新。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -846,7 +840,7 @@ export function App() {
|
|||||||
cooldownUntil: undefined,
|
cooldownUntil: undefined,
|
||||||
}
|
}
|
||||||
: model));
|
: model));
|
||||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels', 'apiKeyPolicyModels');
|
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('模型运行状态已恢复。');
|
setCoreMessage('模型运行状态已恢复。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -863,7 +857,7 @@ export function App() {
|
|||||||
await deletePlatform(token, platformId);
|
await deletePlatform(token, platformId);
|
||||||
setPlatforms((current) => current.filter((item) => item.id !== platformId));
|
setPlatforms((current) => current.filter((item) => item.id !== platformId));
|
||||||
setModels((current) => current.filter((item) => item.platformId !== platformId));
|
setModels((current) => current.filter((item) => item.platformId !== platformId));
|
||||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('平台已删除。');
|
setCoreMessage('平台已删除。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -879,7 +873,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const item = tenantId ? await updateTenant(token, tenantId, input) : await createTenant(token, input);
|
const item = tenantId ? await updateTenant(token, tenantId, input) : await createTenant(token, input);
|
||||||
setTenants((current) => [item, ...current.filter((tenant) => tenant.id !== item.id)]);
|
setTenants((current) => [item, ...current.filter((tenant) => tenant.id !== item.id)]);
|
||||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
invalidateDataKeys('playgroundModels');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage(tenantId ? '租户已更新。' : '租户已创建。');
|
setCoreMessage(tenantId ? '租户已更新。' : '租户已创建。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -895,7 +889,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
await deleteTenant(token, tenantId);
|
await deleteTenant(token, tenantId);
|
||||||
setTenants((current) => current.filter((tenant) => tenant.id !== tenantId));
|
setTenants((current) => current.filter((tenant) => tenant.id !== tenantId));
|
||||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
invalidateDataKeys('playgroundModels');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('租户已删除。');
|
setCoreMessage('租户已删除。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -911,7 +905,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const item = userId ? await updateGatewayUser(token, userId, input) : await createGatewayUser(token, input);
|
const item = userId ? await updateGatewayUser(token, userId, input) : await createGatewayUser(token, input);
|
||||||
setUsers((current) => [item, ...current.filter((user) => user.id !== item.id)]);
|
setUsers((current) => [item, ...current.filter((user) => user.id !== item.id)]);
|
||||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
invalidateDataKeys('playgroundModels');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage(userId ? '用户已更新。' : '用户已创建。');
|
setCoreMessage(userId ? '用户已更新。' : '用户已创建。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -967,7 +961,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input);
|
const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input);
|
||||||
setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]);
|
setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]);
|
||||||
invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels');
|
invalidateDataKeys('modelCatalog', 'playgroundModels');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。');
|
setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -985,7 +979,7 @@ export function App() {
|
|||||||
setUserGroups((current) => current.filter((group) => group.id !== groupId));
|
setUserGroups((current) => current.filter((group) => group.id !== groupId));
|
||||||
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
|
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
|
||||||
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
|
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
|
||||||
invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels');
|
invalidateDataKeys('modelCatalog', 'playgroundModels');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('用户组已删除。');
|
setCoreMessage('用户组已删除。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1039,7 +1033,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
|
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
|
||||||
setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
|
setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
|
||||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
|
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1055,7 +1049,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
await deleteAccessRule(token, ruleId);
|
await deleteAccessRule(token, ruleId);
|
||||||
setAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
|
setAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
|
||||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('访问权限规则已删除。');
|
setCoreMessage('访问权限规则已删除。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1071,7 +1065,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const response = await batchAccessRules(token, input);
|
const response = await batchAccessRules(token, input);
|
||||||
setAccessRules(response.items);
|
setAccessRules(response.items);
|
||||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage('访问权限已更新。');
|
setCoreMessage('访问权限已更新。');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1459,7 +1453,6 @@ export function App() {
|
|||||||
apiKeyForm={apiKeyForm}
|
apiKeyForm={apiKeyForm}
|
||||||
apiKeySecret={apiKeySecret}
|
apiKeySecret={apiKeySecret}
|
||||||
apiKeySecretsById={apiKeySecretsById}
|
apiKeySecretsById={apiKeySecretsById}
|
||||||
apiKeyPolicyModels={apiKeyPolicyModels}
|
|
||||||
data={data}
|
data={data}
|
||||||
message={coreMessage}
|
message={coreMessage}
|
||||||
section={workspaceSection}
|
section={workspaceSection}
|
||||||
@@ -1733,7 +1726,7 @@ function dataKeysForRoute(
|
|||||||
if (activePage === 'workspace') {
|
if (activePage === 'workspace') {
|
||||||
if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys'];
|
if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys'];
|
||||||
if (workspaceSection === 'billing') return ['wallet'];
|
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 === 'tasks') return ['tasks'];
|
||||||
if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions'];
|
if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions'];
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -238,17 +238,17 @@ describe('API Key permission resources', () => {
|
|||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads the user-owned resource pool independently from playable models', async () => {
|
it('loads the key-scoped resource pool independently from playable models', async () => {
|
||||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ items: [] }), {
|
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ items: [], ruleDiagnostics: [] }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}));
|
}));
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
await listApiKeyAssignableModels('user-token');
|
await listApiKeyAssignableModels('user-token', 'key-1');
|
||||||
|
|
||||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
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');
|
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer user-token');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-3
@@ -18,6 +18,7 @@ import type {
|
|||||||
GatewayAccessRuleBatchRequest,
|
GatewayAccessRuleBatchRequest,
|
||||||
GatewayAccessRule,
|
GatewayAccessRule,
|
||||||
GatewayAccessRuleUpsertRequest,
|
GatewayAccessRuleUpsertRequest,
|
||||||
|
GatewayAPIKeyAssignableModelsResponse,
|
||||||
GatewayApiKey,
|
GatewayApiKey,
|
||||||
GatewayApiKeyScopeUpdateRequest,
|
GatewayApiKeyScopeUpdateRequest,
|
||||||
GatewayAuditLog,
|
GatewayAuditLog,
|
||||||
@@ -159,7 +160,7 @@ export async function listModels(token: string): Promise<ListResponse<PlatformMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listPlayableModels(token: string): Promise<ListResponse<PlatformModel>> {
|
export async function listPlayableModels(token: string): Promise<ListResponse<PlatformModel>> {
|
||||||
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
|
return request<ListResponse<PlatformModel>>('/api/v1/platform-models', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listModelCatalog(token: string): Promise<ModelCatalogResponse> {
|
export async function listModelCatalog(token: string): Promise<ModelCatalogResponse> {
|
||||||
@@ -461,8 +462,8 @@ export async function listApiKeyAccessRules(token: string): Promise<ListResponse
|
|||||||
return request<ListResponse<GatewayAccessRule>>('/api/v1/api-keys/access-rules', { token });
|
return request<ListResponse<GatewayAccessRule>>('/api/v1/api-keys/access-rules', { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listApiKeyAssignableModels(token: string): Promise<ListResponse<PlatformModel>> {
|
export async function listApiKeyAssignableModels(token: string, apiKeyId: string): Promise<GatewayAPIKeyAssignableModelsResponse> {
|
||||||
return request<ListResponse<PlatformModel>>('/api/v1/api-keys/assignable-models', { token });
|
return request<GatewayAPIKeyAssignableModelsResponse>(`/api/v1/api-keys/${apiKeyId}/assignable-models`, { token });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise<GatewayAccessRule> {
|
export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise<GatewayAccessRule> {
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ describe('ApiDocsPage extended task documentation', () => {
|
|||||||
it('separates the complete public catalog into common and compatibility interfaces', () => {
|
it('separates the complete public catalog into common and compatibility interfaces', () => {
|
||||||
const endpoints = publicApiCatalogGroups.flatMap((group) => group.endpoints);
|
const endpoints = publicApiCatalogGroups.flatMap((group) => group.endpoints);
|
||||||
|
|
||||||
expect(publicApiEndpointCount()).toBe(71);
|
expect(publicApiEndpointCount()).toBe(73);
|
||||||
expect(publicApiEndpointCount('open')).toBe(54);
|
expect(publicApiEndpointCount('open')).toBe(56);
|
||||||
expect(publicApiEndpointCount('compatibility')).toBe(17);
|
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', () => {
|
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('兼容接口');
|
||||||
expect(html).toContain('https://ai.51easyai.com/api/v1');
|
expect(html).toContain('https://ai.51easyai.com/api/v1');
|
||||||
expect(html).toContain('接口数量');
|
expect(html).toContain('接口数量');
|
||||||
expect(html).toContain('>54<');
|
expect(html).toContain('>56<');
|
||||||
expect(html).toContain('/api/v1/videos/generations');
|
expect(html).toContain('/api/v1/videos/generations');
|
||||||
expect(html).toContain('/api/v1/resource/material');
|
expect(html).toContain('/api/v1/resource/material');
|
||||||
expect(html).toContain('不要求调用方显式传入');
|
expect(html).toContain('不要求调用方显式传入');
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
import { useEffect, useMemo, useRef, useState, type FormEvent, type ReactNode } from 'react';
|
||||||
import { Popover as AntPopover } from 'antd';
|
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 { 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 type { ConsoleData } from '../app-state';
|
||||||
import { EntityTable } from '../components/EntityTable';
|
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 { 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 { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor';
|
||||||
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery, WorkspaceTransactionQuery } from '../types';
|
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery, WorkspaceTransactionQuery } from '../types';
|
||||||
import { listTaskParamPreprocessing } from '../api';
|
import { listApiKeyAssignableModels, listTaskParamPreprocessing } from '../api';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ value: 'overview', label: '个人总览', icon: <UserRound size={15} /> },
|
{ value: 'overview', label: '个人总览', icon: <UserRound size={15} /> },
|
||||||
@@ -24,9 +24,12 @@ const apiKeyScopeOptions = [
|
|||||||
{ value: 'embedding', label: '向量', description: 'Embeddings' },
|
{ value: 'embedding', label: '向量', description: 'Embeddings' },
|
||||||
{ value: 'rerank', label: '重排', description: 'Reranks' },
|
{ value: 'rerank', label: '重排', description: 'Reranks' },
|
||||||
{ value: 'image', label: '图像', description: 'Images' },
|
{ value: 'image', label: '图像', description: 'Images' },
|
||||||
|
{ value: 'image_vectorize', label: '图像向量化', description: 'Image Vectorize' },
|
||||||
{ value: 'video', label: '视频', description: 'Videos' },
|
{ value: 'video', label: '视频', description: 'Videos' },
|
||||||
|
{ value: 'video_enhance', label: '视频增强', description: 'Video Upscale / Enhance' },
|
||||||
{ value: 'music', label: '音乐生成', description: 'Song / Music' },
|
{ value: 'music', label: '音乐生成', description: 'Song / Music' },
|
||||||
{ value: 'audio', label: '语音合成', description: 'Speech / TTS' },
|
{ value: 'audio', label: '语音合成', description: 'Speech / TTS' },
|
||||||
|
{ value: 'voice_clone', label: '声音克隆', description: 'Voice Clone' },
|
||||||
{ value: 'all', label: '全部能力', description: '不按接口能力限制' },
|
{ value: 'all', label: '全部能力', description: '不按接口能力限制' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -36,7 +39,6 @@ export function WorkspacePage(props: {
|
|||||||
apiKeyForm: ApiKeyForm;
|
apiKeyForm: ApiKeyForm;
|
||||||
apiKeySecret: string;
|
apiKeySecret: string;
|
||||||
apiKeySecretsById: Record<string, string>;
|
apiKeySecretsById: Record<string, string>;
|
||||||
apiKeyPolicyModels: PlatformModel[];
|
|
||||||
data: ConsoleData;
|
data: ConsoleData;
|
||||||
message: string;
|
message: string;
|
||||||
section: WorkspaceSection;
|
section: WorkspaceSection;
|
||||||
@@ -389,10 +391,10 @@ function ApiKeyPanel(props: {
|
|||||||
apiKeyForm: ApiKeyForm;
|
apiKeyForm: ApiKeyForm;
|
||||||
apiKeySecret: string;
|
apiKeySecret: string;
|
||||||
apiKeySecretsById: Record<string, string>;
|
apiKeySecretsById: Record<string, string>;
|
||||||
apiKeyPolicyModels: PlatformModel[];
|
|
||||||
data: ConsoleData;
|
data: ConsoleData;
|
||||||
message: string;
|
message: string;
|
||||||
state: LoadState;
|
state: LoadState;
|
||||||
|
token: string;
|
||||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||||
@@ -407,6 +409,10 @@ function ApiKeyPanel(props: {
|
|||||||
const [scopeError, setScopeError] = useState('');
|
const [scopeError, setScopeError] = useState('');
|
||||||
const [pendingDelete, setPendingDelete] = useState<GatewayApiKey | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<GatewayApiKey | null>(null);
|
||||||
const [localMessage, setLocalMessage] = useState('');
|
const [localMessage, setLocalMessage] = useState('');
|
||||||
|
const [policyModels, setPolicyModels] = useState<PlatformModel[]>([]);
|
||||||
|
const [policyDiagnostics, setPolicyDiagnostics] = useState<GatewayAPIKeyAccessRuleDiagnostic[]>([]);
|
||||||
|
const [policyState, setPolicyState] = useState<LoadState>('idle');
|
||||||
|
const [policyError, setPolicyError] = useState('');
|
||||||
const selectedPolicyKey = useMemo(
|
const selectedPolicyKey = useMemo(
|
||||||
() => props.data.apiKeys.find((item) => item.id === policyApiKeyId),
|
() => props.data.apiKeys.find((item) => item.id === policyApiKeyId),
|
||||||
[policyApiKeyId, props.data.apiKeys],
|
[policyApiKeyId, props.data.apiKeys],
|
||||||
@@ -415,7 +421,44 @@ function ApiKeyPanel(props: {
|
|||||||
() => props.data.apiKeys.find((item) => item.id === scopeApiKeyId),
|
() => props.data.apiKeys.find((item) => item.id === scopeApiKeyId),
|
||||||
[scopeApiKeyId, props.data.apiKeys],
|
[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) {
|
async function copyApiKey(item: GatewayApiKey) {
|
||||||
const secret = apiKeySecretFor(item, props.apiKeySecretsById);
|
const secret = apiKeySecretFor(item, props.apiKeySecretsById);
|
||||||
@@ -620,15 +663,17 @@ function ApiKeyPanel(props: {
|
|||||||
onClose={() => setPolicyApiKeyId('')}
|
onClose={() => setPolicyApiKeyId('')}
|
||||||
onSubmit={(event) => event.preventDefault()}
|
onSubmit={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
|
{policyError && <p className="formMessage error">{policyError}</p>}
|
||||||
<AccessPermissionEditor
|
<AccessPermissionEditor
|
||||||
accessRules={props.data.accessRules}
|
accessRules={props.data.accessRules}
|
||||||
metadataMode="api_key_permission_tree"
|
metadataMode="api_key_permission_tree"
|
||||||
platformModels={props.apiKeyPolicyModels}
|
platformModels={policyModels}
|
||||||
platforms={permissionPlatforms}
|
platforms={permissionPlatforms}
|
||||||
state={props.state}
|
ruleDiagnostics={policyDiagnostics}
|
||||||
|
state={policyState}
|
||||||
subjectId={selectedPolicyKey?.id ?? ''}
|
subjectId={selectedPolicyKey?.id ?? ''}
|
||||||
subjectType="api_key"
|
subjectType="api_key"
|
||||||
onBatchAccessRules={props.onBatchAccessRules}
|
onBatchAccessRules={savePolicyRules}
|
||||||
/>
|
/>
|
||||||
</FormDialog>
|
</FormDialog>
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<AccessPermissionEditor
|
||||||
|
accessRules={[]}
|
||||||
|
platformModels={[model]}
|
||||||
|
platforms={[platform]}
|
||||||
|
ruleDiagnostics={[{
|
||||||
|
ruleId: 'rule-1',
|
||||||
|
resourceType: 'platform_model',
|
||||||
|
resourceId: 'text-model',
|
||||||
|
resourceName: 'Text Model',
|
||||||
|
effect: 'allow',
|
||||||
|
effective: false,
|
||||||
|
reason: 'scope_not_allowed',
|
||||||
|
}]}
|
||||||
|
state="ready"
|
||||||
|
subjectId="key-1"
|
||||||
|
subjectType="api_key"
|
||||||
|
onBatchAccessRules={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import type {
|
import type {
|
||||||
GatewayAccessEffect,
|
GatewayAccessEffect,
|
||||||
|
GatewayAPIKeyAccessRuleDiagnostic,
|
||||||
GatewayAccessRule,
|
GatewayAccessRule,
|
||||||
GatewayAccessRuleBatchRequest,
|
GatewayAccessRuleBatchRequest,
|
||||||
GatewayAccessRuleResourceRequest,
|
GatewayAccessRuleResourceRequest,
|
||||||
@@ -14,7 +15,7 @@ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, Inpu
|
|||||||
import type { LoadState } from '../../types';
|
import type { LoadState } from '../../types';
|
||||||
|
|
||||||
type Effect = Extract<GatewayAccessEffect, 'allow' | 'deny'>;
|
type Effect = Extract<GatewayAccessEffect, 'allow' | 'deny'>;
|
||||||
type ResourceType = Extract<GatewayAccessResourceType, 'platform' | 'platform_model'>;
|
type ResourceType = GatewayAccessResourceType;
|
||||||
type ResourceKey = `${ResourceType}:${string}`;
|
type ResourceKey = `${ResourceType}:${string}`;
|
||||||
|
|
||||||
type PlatformNode = {
|
type PlatformNode = {
|
||||||
@@ -34,6 +35,7 @@ export function AccessPermissionEditor(props: {
|
|||||||
metadataMode?: string;
|
metadataMode?: string;
|
||||||
platformModels: PlatformModel[];
|
platformModels: PlatformModel[];
|
||||||
platforms: IntegrationPlatform[];
|
platforms: IntegrationPlatform[];
|
||||||
|
ruleDiagnostics?: GatewayAPIKeyAccessRuleDiagnostic[];
|
||||||
state: LoadState;
|
state: LoadState;
|
||||||
subjectId: string;
|
subjectId: string;
|
||||||
subjectType: Extract<GatewayAccessSubjectType, 'user_group' | 'api_key'>;
|
subjectType: Extract<GatewayAccessSubjectType, 'user_group' | 'api_key'>;
|
||||||
@@ -51,6 +53,10 @@ export function AccessPermissionEditor(props: {
|
|||||||
[props.accessRules, props.subjectId, props.subjectType],
|
[props.accessRules, props.subjectId, props.subjectType],
|
||||||
);
|
);
|
||||||
const ruleByEffectAndResource = useMemo(() => buildRuleIndex(subjectRules), [subjectRules]);
|
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 allowTree = useMemo(() => filterTree(platformTree, allowSearch), [allowSearch, platformTree]);
|
||||||
const denyTree = useMemo(() => filterTree(platformTree, denySearch), [denySearch, 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) {
|
async function setPlatformPermission(effect: Effect, platform: PlatformNode, enabled: boolean) {
|
||||||
const keys = [
|
const modelKeys = platform.models.map((model) => makeResourceKey('platform_model', model.id));
|
||||||
makeResourceKey('platform', platform.id),
|
if (enabled && props.subjectType === 'api_key') {
|
||||||
...platform.models.map((model) => makeResourceKey('platform_model', model.id)),
|
await batchApply(effect, modelKeys, true);
|
||||||
];
|
return;
|
||||||
await batchApply(effect, keys, enabled);
|
}
|
||||||
|
await batchApply(effect, [makeResourceKey('platform', platform.id), ...modelKeys], enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectAll(effect: Effect, nodes: PlatformNode[]) {
|
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[]) {
|
async function reverseVisible(effect: Effect, nodes: PlatformNode[]) {
|
||||||
const keys = visibleResourceKeys(nodes);
|
const keys = visibleResourceKeys(nodes, props.subjectType !== 'api_key');
|
||||||
const upsertKeys: ResourceKey[] = [];
|
const upsertKeys: ResourceKey[] = [];
|
||||||
const deleteKeys: ResourceKey[] = [];
|
const deleteKeys: ResourceKey[] = [];
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
@@ -88,6 +95,16 @@ export function AccessPermissionEditor(props: {
|
|||||||
await applyPermissionBatch(effect, upsertKeys, deleteKeys);
|
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) {
|
async function clearEffect(effect: Effect) {
|
||||||
const keys = subjectRules
|
const keys = subjectRules
|
||||||
.filter((rule) => rule.effect === effect)
|
.filter((rule) => rule.effect === effect)
|
||||||
@@ -138,11 +155,32 @@ export function AccessPermissionEditor(props: {
|
|||||||
return (
|
return (
|
||||||
<div className="accessEditorStack">
|
<div className="accessEditorStack">
|
||||||
{localError && <p className="formMessage">{localError}</p>}
|
{localError && <p className="formMessage">{localError}</p>}
|
||||||
|
{ineffectiveRules.length > 0 && (
|
||||||
|
<section className="accessRuleDiagnostics" aria-label="失效 API Key 权限规则">
|
||||||
|
<div>
|
||||||
|
<strong>{ineffectiveRules.length} 条规则当前不生效</strong>
|
||||||
|
<span>规则已保留,不会扩大权限或阻断其他 API Key。</span>
|
||||||
|
</div>
|
||||||
|
{ineffectiveRules.map((diagnostic) => (
|
||||||
|
<div className="accessRuleDiagnosticRow" key={diagnostic.ruleId}>
|
||||||
|
<span>
|
||||||
|
<Badge variant="outline">{diagnostic.effect === 'allow' ? '专属' : '排除'}</Badge>
|
||||||
|
<strong>{diagnostic.resourceName || diagnostic.resourceId}</strong>
|
||||||
|
<small>{accessRuleDiagnosticReason(diagnostic.reason)}</small>
|
||||||
|
</span>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={() => void removeIneffectiveRule(diagnostic)}>
|
||||||
|
移除规则
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
<section className="accessPermissionGrid">
|
<section className="accessPermissionGrid">
|
||||||
<PermissionTreePanel
|
<PermissionTreePanel
|
||||||
emptyText="暂无可维护的平台模型"
|
emptyText="暂无可维护的平台模型"
|
||||||
effect="allow"
|
effect="allow"
|
||||||
expanded={allowExpanded}
|
expanded={allowExpanded}
|
||||||
|
modelOnlySelection={props.subjectType === 'api_key'}
|
||||||
rules={ruleByEffectAndResource}
|
rules={ruleByEffectAndResource}
|
||||||
search={allowSearch}
|
search={allowSearch}
|
||||||
state={props.state}
|
state={props.state}
|
||||||
@@ -163,6 +201,7 @@ export function AccessPermissionEditor(props: {
|
|||||||
emptyText="暂无可维护的平台模型"
|
emptyText="暂无可维护的平台模型"
|
||||||
effect="deny"
|
effect="deny"
|
||||||
expanded={denyExpanded}
|
expanded={denyExpanded}
|
||||||
|
modelOnlySelection={props.subjectType === 'api_key'}
|
||||||
rules={ruleByEffectAndResource}
|
rules={ruleByEffectAndResource}
|
||||||
search={denySearch}
|
search={denySearch}
|
||||||
state={props.state}
|
state={props.state}
|
||||||
@@ -188,6 +227,7 @@ function PermissionTreePanel(props: {
|
|||||||
effect: Effect;
|
effect: Effect;
|
||||||
emptyText: string;
|
emptyText: string;
|
||||||
expanded: Set<string>;
|
expanded: Set<string>;
|
||||||
|
modelOnlySelection: boolean;
|
||||||
rules: Map<string, GatewayAccessRule>;
|
rules: Map<string, GatewayAccessRule>;
|
||||||
search: string;
|
search: string;
|
||||||
state: LoadState;
|
state: LoadState;
|
||||||
@@ -229,6 +269,7 @@ function PermissionTreePanel(props: {
|
|||||||
key={platform.id}
|
key={platform.id}
|
||||||
platform={platform}
|
platform={platform}
|
||||||
rules={props.rules}
|
rules={props.rules}
|
||||||
|
modelOnlySelection={props.modelOnlySelection}
|
||||||
onToggleExpanded={props.onToggleExpanded}
|
onToggleExpanded={props.onToggleExpanded}
|
||||||
onTogglePlatformPermission={props.onTogglePlatformPermission}
|
onTogglePlatformPermission={props.onTogglePlatformPermission}
|
||||||
onTogglePermission={props.onTogglePermission}
|
onTogglePermission={props.onTogglePermission}
|
||||||
@@ -245,6 +286,7 @@ function PlatformPermissionNode(props: {
|
|||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
platform: PlatformNode;
|
platform: PlatformNode;
|
||||||
rules: Map<string, GatewayAccessRule>;
|
rules: Map<string, GatewayAccessRule>;
|
||||||
|
modelOnlySelection: boolean;
|
||||||
onToggleExpanded: (platformId: string) => void;
|
onToggleExpanded: (platformId: string) => void;
|
||||||
onTogglePlatformPermission: (effect: Effect, platform: PlatformNode, enabled: boolean) => void;
|
onTogglePlatformPermission: (effect: Effect, platform: PlatformNode, enabled: boolean) => void;
|
||||||
onTogglePermission: (effect: Effect, resourceType: ResourceType, resourceId: string, 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 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 checkedModels = props.platform.models.filter((model) => props.rules.has(`${props.effect}:${makeResourceKey('platform_model', model.id)}`)).length;
|
||||||
const platformChecked = props.rules.has(platformRuleKey);
|
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 (
|
return (
|
||||||
<div className="accessTreeNode">
|
<div className="accessTreeNode">
|
||||||
<div className="accessTreeRow">
|
<div className="accessTreeRow">
|
||||||
@@ -352,9 +404,9 @@ function countEffectRules(rules: GatewayAccessRule[], effect: Effect) {
|
|||||||
}, { platforms: 0, models: 0 });
|
}, { platforms: 0, models: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleResourceKeys(nodes: PlatformNode[]): ResourceKey[] {
|
function visibleResourceKeys(nodes: PlatformNode[], includePlatforms: boolean): ResourceKey[] {
|
||||||
return nodes.flatMap((platform) => [
|
return nodes.flatMap((platform) => [
|
||||||
makeResourceKey('platform', platform.id),
|
...(includePlatforms ? [makeResourceKey('platform', platform.id)] : []),
|
||||||
...platform.models.map((model) => makeResourceKey('platform_model', model.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 {
|
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);
|
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[]) {
|
function dedupeResourceKeys(keys: ResourceKey[]) {
|
||||||
return Array.from(new Set(keys.filter(Boolean)));
|
return Array.from(new Set(keys.filter(Boolean)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const publicApiCatalogGroups: PublicApiCatalogGroup[] = [
|
|||||||
{ method: 'POST', path: '/api/v1/api-keys', description: '创建 API Key' },
|
{ method: 'POST', path: '/api/v1/api-keys', description: '创建 API Key' },
|
||||||
{ method: 'GET', path: '/api/v1/api-keys/access-rules', description: '查询 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: '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}/scopes', description: '更新 Key 权限范围' },
|
||||||
{ method: 'PATCH', path: '/api/v1/api-keys/{apiKeyID}/disable', description: '禁用 Key' },
|
{ method: 'PATCH', path: '/api/v1/api-keys/{apiKeyID}/disable', description: '禁用 Key' },
|
||||||
{ method: 'DELETE', path: '/api/v1/api-keys/{apiKeyID}', description: '删除 Key' },
|
{ method: 'DELETE', path: '/api/v1/api-keys/{apiKeyID}', description: '删除 Key' },
|
||||||
@@ -77,7 +77,9 @@ export const publicApiCatalogGroups: PublicApiCatalogGroup[] = [
|
|||||||
endpoints: [
|
endpoints: [
|
||||||
{ method: 'GET', path: '/api/v1/model-catalog', description: '模型能力目录' },
|
{ method: 'GET', path: '/api/v1/model-catalog', description: '模型能力目录' },
|
||||||
{ method: 'GET', path: '/api/v1/platforms', 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: 'GET', path: '/api/v1/playground/models', description: 'Playground 可用模型' },
|
||||||
{ method: 'POST', path: '/api/v1/pricing/estimate', description: '请求价格预估' },
|
{ method: 'POST', path: '/api/v1/pricing/estimate', description: '请求价格预估' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -467,6 +467,48 @@
|
|||||||
gap: 12px;
|
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 {
|
.accessPermissionPanel .shCardContent {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ https://ai.51easyai.com/api/v1
|
|||||||
| GET, POST | `/api/v1/api-keys` | 查询、创建 API Key |
|
| GET, POST | `/api/v1/api-keys` | 查询、创建 API Key |
|
||||||
| GET | `/api/v1/api-keys/access-rules` | 查询 Key 访问规则 |
|
| GET | `/api/v1/api-keys/access-rules` | 查询 Key 访问规则 |
|
||||||
| POST | `/api/v1/api-keys/access-rules/batch` | 批量设置 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}/scopes` | 更新 Key 权限范围 |
|
||||||
| PATCH | `/api/v1/api-keys/{apiKeyID}/disable` | 禁用 Key |
|
| PATCH | `/api/v1/api-keys/{apiKeyID}/disable` | 禁用 Key |
|
||||||
| DELETE | `/api/v1/api-keys/{apiKeyID}` | 删除 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/model-catalog` | 模型能力目录 |
|
||||||
| GET | `/api/v1/platforms` | 当前用户可用平台 |
|
| 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 可用模型 |
|
| GET | `/api/v1/playground/models` | Playground 可用模型 |
|
||||||
| POST | `/api/v1/pricing/estimate` | 请求价格预估 |
|
| POST | `/api/v1/pricing/estimate` | 请求价格预估 |
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| SETUP-01 | 确认服务可用 | `GET /api/v1/healthz`、`GET /api/v1/readyz` | `healthz.ok=true`,`readyz.ok=true` | 未执行 | 待填写 |
|
| 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-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-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-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` | 未执行 | 待填写 |
|
| SETUP-06 | 创建内部定价规则集 | `POST /api/v1/pricing/rule-sets` | 规则覆盖 `text_input`、`text_output`、`image`、`image_edit`、`video` | 未执行 | 待填写 |
|
||||||
|
|||||||
@@ -530,6 +530,23 @@ export interface GatewayAccessRuleBatchRequest {
|
|||||||
deleteResources?: GatewayAccessRuleResourceRequest[];
|
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 {
|
export interface GatewayApiKey {
|
||||||
id: string;
|
id: string;
|
||||||
gatewayTenantId?: string;
|
gatewayTenantId?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user