refactor(access): 统一分层白名单权限语义

取消跨主体专属占用,按租户、用户组、用户、当前 API Key 和 scope 分层求交,并在任务落库前统一校验候选。\n\n增加旧 allow 规则归档清理迁移、脱敏审计工具和回滚运行手册,补齐主体隔离、deny 优先及列表与运行时一致性测试。
This commit is contained in:
2026-08-03 15:43:49 +08:00
parent c9393af43a
commit 7376d6fab6
20 changed files with 1490 additions and 513 deletions
@@ -12,7 +12,7 @@ import (
// listAccessRules godoc
// @Summary 列出访问规则
// @Description 管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的访问规则。
// @Description 管理端返回用户组、租户、用户或 API Key 到平台、平台模型、基础模型的分层访问规则。主体当前层无 allow 时继承上级,存在 allow 时仅允许白名单,deny 始终优先。
// @Tags access-rules
// @Produce json
// @Security BearerAuth
@@ -33,7 +33,7 @@ func (s *Server) listAccessRules(w http.ResponseWriter, r *http.Request) {
// listAPIKeyAccessRules godoc
// @Summary 列出 API Key 访问规则
// @Description 返回当前本地用户可管理的 API Key 访问规则。
// @Description 返回当前本地用户拥有的 API Key 访问规则;不会混入其他用户或其他 API Key 的规则
// @Tags api-keys
// @Produce json
// @Security BearerAuth
@@ -60,7 +60,7 @@ func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
// listAPIKeyAssignableModels godoc
// @Summary 列出 API Key 可分配模型
// @Description 按当前用户自身的户、租户和用户组权限返回可分配给 API Key 的启用模型,不任何 API Key 权限规则影响
// @Description 按当前用户自身的户、用户组和用户分层白名单返回可分配给 API Key 的启用模型,不应用任何 API Key 层规则
// @Tags api-keys
// @Produce json
// @Security BearerAuth
@@ -88,7 +88,7 @@ func (s *Server) listAPIKeyAssignableModels(w http.ResponseWriter, r *http.Reque
// listAPIKeyAssignableModelsForKey godoc
// @Summary 列出指定 API Key 可分配模型
// @Description 返回指定 API Key 所属用户组允许、全局启用且符合 KEY scope 的平台来源,并附带已有规则有效性诊断。
// @Description 返回全局启用、命中指定 API Key 的租户/用户组/用户基线且符合 Key scope 的可分配平台来源;当前 Key 的 allow/deny 不缩减候选,仅作为已有规则有效性诊断返回
// @Tags api-keys
// @Produce json
// @Security BearerAuth
@@ -123,7 +123,7 @@ func (s *Server) listAPIKeyAssignableModelsForKey(w http.ResponseWriter, r *http
// createAccessRule godoc
// @Summary 创建访问规则
// @Description 管理端创建一条访问控制规则。
// @Description 管理端创建一条访问控制规则;同一主体层存在任意有效 allow 后该层启用白名单,deny 始终优先
// @Tags access-rules
// @Accept json
// @Produce json
@@ -161,7 +161,7 @@ func (s *Server) createAccessRule(w http.ResponseWriter, r *http.Request) {
// batchAccessRules godoc
// @Summary 批量写入访问规则
// @Description 管理端为同一主体批量新增、更新或删除资源访问规则。
// @Description 管理端为同一主体批量新增、更新或删除资源访问规则。清空该主体全部 allow 会恢复上级继承。
// @Tags access-rules
// @Accept json
// @Produce json
@@ -194,7 +194,7 @@ func (s *Server) batchAccessRules(w http.ResponseWriter, r *http.Request) {
// batchAPIKeyAccessRules godoc
// @Summary 批量写入 API Key 访问规则
// @Description 当前本地用户为自己的 API Key 批量新增、更新或删除可访问资源
// @Description 当前本地用户为自己的 API Key 批量新增、更新或删除白名单/拒绝资源;Key 无 allow 时继承父级范围,存在 allow 后仅允许命中项
// @Tags api-keys
// @Accept json
// @Produce json
@@ -222,9 +222,16 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
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, multiModel.ID, "allow")
createRule("user_group", groupAID, multiSecondModel.ID, "allow")
createRule("user_group", groupAID, keyDenyImageModel.ID, "allow")
createRule("user_group", groupAID, scopeTextModel.ID, "allow")
createRule("user_group", groupAID, textModel.ID, "deny")
createRule("user_group", groupAID, legacyTextModel.ID, "deny")
createRule("user_group", groupBID, groupBImageModel.ID, "allow")
createRule("user_group", groupBID, textModel.ID, "allow")
createRule("user_group", groupBID, scopeTextModel.ID, "allow")
createRule("user_group", groupBID, legacyTextModel.ID, "allow")
doJSON(t, server.URL, http.MethodPatch, "/api/v1/api-keys/"+keyScopeID+"/scopes", adminToken, map[string]any{
"scopes": []string{"image"},
}, http.StatusOK, nil)
@@ -233,6 +240,7 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
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, "allow", http.StatusOK)
batchKeyRule(adminToken, keyAID, keyDenyImageModel.ID, "deny", http.StatusOK)
loadAssignable := func(token string, keyID string) modelAccessAssignableResponse {
@@ -252,7 +260,7 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
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)
t.Fatalf("disabled or group-A-whitelist-excluded 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") {
@@ -292,22 +300,22 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
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) {
if !containsModelID(platformModels.Items, imageModel.ID) || containsModelID(platformModels.Items, multiModel.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")
t.Fatalf("assignable list must ignore another key's whitelist")
}
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)
if !containsModelID(inheritedPlatformModels.Items, imageModel.ID) || !containsModelID(inheritedPlatformModels.Items, keyDenyImageModel.ID) || !containsModelID(inheritedPlatformModels.Items, multiModel.ID) {
t.Fatalf("key whitelist/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)
t.Fatalf("group whitelist rules were not applied to key candidates: %+v", groupBAssignable.Items)
}
var groupBChatModels struct {
Items []modelAccessFixture `json:"items"`
@@ -326,7 +334,7 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
ID string `json:"id"`
} `json:"data"`
}
doJSON(t, server.URL, http.MethodGet, "/v1/models", keyASecret, nil, http.StatusOK, &openAIList)
doJSON(t, server.URL, http.MethodGet, "/v1/models", keyAInheritedSecret, nil, http.StatusOK, &openAIList)
if openAIList.Object != "list" || countOpenAIModel(openAIList.Data, multiName) != 1 {
t.Fatalf("openai model list is not deduplicated: %+v", openAIList)
}
@@ -373,21 +381,34 @@ func TestAPIKeyModelAccessUsesGroupKeyAndScopeLayers(t *testing.T) {
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,
"model": imageName, "prompt": "key whitelist", "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)
}, http.StatusOK, 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,
"model": groupBImageName, "prompt": "group B whitelist", "runMode": "simulation", "simulation": true,
}, http.StatusOK, nil)
t.Logf("实际调用:KEY 专属 200,兄弟 KEY 404KEY 排除 404,组 B 专属 200")
var taskCountBefore, taskCountAfter int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks`).Scan(&taskCountBefore); err != nil {
t.Fatalf("count tasks before rejected whitelist request: %v", err)
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/images/generations", keyASecret, map[string]any{
"model": multiName, "prompt": "not in current key whitelist", "runMode": "simulation", "simulation": true,
}, http.StatusNotFound, nil)
if err := pool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks`).Scan(&taskCountAfter); err != nil {
t.Fatalf("count tasks after rejected whitelist request: %v", err)
}
if taskCountAfter != taskCountBefore {
t.Fatalf("rejected whitelist request created a task: before=%d after=%d", taskCountBefore, taskCountAfter)
}
t.Logf("实际调用:KEY 白名单允许 200,兄弟 KEY 不受影响 200KEY deny 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.
// Group B can still use text: group A and stale API-key allows are never
// evaluated for this group's keys.
if !containsModelID(loadAssignable(userBToken, keyBID).Items, textModel.ID) {
t.Fatalf("group B chat key candidate list lost the group-A denied model")
}
@@ -824,21 +824,22 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
"minPermissionLevel": 0,
"status": "active",
}, http.StatusCreated, nil)
var deniedTask struct {
Task struct {
Status string `json:"status"`
ErrorCode string `json:"errorCode"`
} `json:"task"`
var deniedTaskCountBefore, deniedTaskCountAfter int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks`).Scan(&deniedTaskCountBefore); err != nil {
t.Fatalf("count tasks before denied model request: %v", err)
}
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, chatOnlyAPIKeyResponse.Secret, map[string]any{
doJSON(t, server.URL, http.MethodPost, "/api/v1/chat/completions", chatOnlyAPIKeyResponse.Secret, map[string]any{
"model": deniedModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"messages": []map[string]any{{"role": "user", "content": "permission deny"}},
}, "permission-deny-"+suffixText, http.StatusNotFound, nil, &deniedTask.Task)
if deniedTask.Task.Status != "failed" || deniedTask.Task.ErrorCode != "no_model_candidate" {
t.Fatalf("deny access rule should hide denied model from runtime candidates: %+v", deniedTask.Task)
}, http.StatusNotFound, nil)
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM gateway_tasks`).Scan(&deniedTaskCountAfter); err != nil {
t.Fatalf("count tasks after denied model request: %v", err)
}
if deniedTaskCountAfter != deniedTaskCountBefore {
t.Fatalf("deny access rule rejection created a task: before=%d after=%d", deniedTaskCountBefore, deniedTaskCountAfter)
}
var restrictedModels struct {
Items []struct {
@@ -873,38 +874,27 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
"minPermissionLevel": 0,
"status": "active",
}, http.StatusCreated, nil)
var blockedControlledTask struct {
Task struct {
Status string `json:"status"`
ErrorCode string `json:"errorCode"`
} `json:"task"`
}
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, chatOnlyAPIKeyResponse.Secret, map[string]any{
"model": controlledModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"messages": []map[string]any{{"role": "user", "content": "allow should block other keys"}},
}, "permission-allow-block-"+suffixText, http.StatusNotFound, nil, &blockedControlledTask.Task)
if blockedControlledTask.Task.Status != "failed" || blockedControlledTask.Task.ErrorCode != "no_model_candidate" {
t.Fatalf("allow access rule should make the resource unavailable to unmatched subjects: %+v", blockedControlledTask.Task)
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/access-rules", loginResponse.AccessToken, map[string]any{
"subjectType": "api_key",
"subjectId": chatOnlyAPIKeyResponse.APIKey.ID,
"resourceType": "platform_model",
"resourceId": controlledPlatformModel.ID,
"effect": "allow",
"priority": 10,
"minPermissionLevel": 0,
"status": "active",
}, http.StatusCreated, nil)
var allowedControlledTask struct {
var siblingControlledTask struct {
Task struct {
Status string `json:"status"`
} `json:"task"`
}
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, chatOnlyAPIKeyResponse.Secret, map[string]any{
"model": controlledModel,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"messages": []map[string]any{{"role": "user", "content": "another key whitelist must not affect this key"}},
}, "permission-allow-sibling-"+suffixText, http.StatusOK, nil, &siblingControlledTask.Task)
if siblingControlledTask.Task.Status != "succeeded" {
t.Fatalf("another key's allow rule must not restrict this key: %+v", siblingControlledTask.Task)
}
var allowedControlledTask struct {
Task struct {
Status string `json:"status"`
} `json:"task"`
}
doAPIV1ChatCompletionAndLoadTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, map[string]any{
"model": controlledModel,
"runMode": "simulation",
"simulation": true,
@@ -912,7 +902,7 @@ LIMIT 1`).Scan(&gptImageModelTypesRaw); err != nil {
"messages": []map[string]any{{"role": "user", "content": "allow should pass"}},
}, "permission-allow-pass-"+suffixText, http.StatusOK, nil, &allowedControlledTask.Task)
if allowedControlledTask.Task.Status != "succeeded" {
t.Fatalf("matching allow access rule should make the controlled model usable: %+v", allowedControlledTask.Task)
t.Fatalf("matching current-key allow rule should make the controlled model usable: %+v", allowedControlledTask.Task)
}
var customPricingRuleSet struct {
+11 -1
View File
@@ -632,7 +632,7 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) {
// listPlayableModels godoc
// @Summary 列出可调用模型
// @Description 按当前用户权限返回可用于 Playground 或 API 调用的模型列表
// @Description 按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回可用于 Playground 或 API 调用的平台来源;其他主体规则不参与求值
// @Tags playground
// @Produce json
// @Security BearerAuth
@@ -1201,6 +1201,16 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
writeTaskError(status, err.Error(), nil, clients.ErrorCode(err))
return
}
if err := s.runner.ValidateModelAccess(r.Context(), kind, model, prepared.Body, user); err != nil {
if errors.Is(err, store.ErrNoModelCandidate) {
applyRunErrorHeaders(w, err)
writeTaskError(statusFromRunError(err), runErrorMessage(err), runErrorDetails(err), runErrorCode(err))
return
}
s.logger.Error("validate model access failed", "kind", kind, "error_category", "model_access_validation_failed", "error", err)
writeTaskError(http.StatusInternalServerError, "validate model access failed", nil, "model_access_validation_failed")
return
}
runMode, err := s.admittedTaskRunMode(admission, prepared.Body)
if err != nil {
@@ -26,7 +26,7 @@ func (s *Server) listLegacyPlayableModels(w http.ResponseWriter, r *http.Request
// listOpenAIModels godoc
// @Summary 列出 OpenAI 兼容模型
// @Description 按当前身份、API Key 访问规则及 scope 返回去重后的逻辑模型列表。
// @Description 按全局启用、租户、用户组、用户、当前 API Key 分层白名单及 scope 的交集返回去重后的逻辑模型列表;其他主体规则不参与求值
// @Tags openai-compatible
// @Produce json
// @Security BearerAuth