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:
2026-08-03 09:17:15 +08:00
parent c28bf74230
commit cc97e6649c
26 changed files with 2249 additions and 220 deletions
+166
View File
@@ -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)
}
})
}
}