refactor(access): 统一分层白名单权限语义
取消跨主体专属占用,按租户、用户组、用户、当前 API Key 和 scope 分层求交,并在任务落库前统一校验候选。\n\n增加旧 allow 规则归档清理迁移、脱敏审计工具和回滚运行手册,补齐主体隔离、deny 优先及列表与运行时一致性测试。
This commit is contained in:
@@ -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 404,KEY 排除 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 不受影响 200,KEY 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user