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:
@@ -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)), "-", "_")
|
||||
}
|
||||
Reference in New Issue
Block a user