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
@@ -64,12 +64,14 @@ func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
// @Tags api-keys
// @Produce json
// @Security BearerAuth
// @Deprecated
// @Success 200 {object} PlatformModelListResponse
// @Failure 401 {object} ErrorEnvelope
// @Failure 503 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/api-keys/assignable-models [get]
func (s *Server) listAPIKeyAssignableModels(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Deprecation", "true")
user, _ := auth.UserFromContext(r.Context())
models, err := s.store.ListAPIKeyAssignablePlatformModels(r.Context(), user)
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)})
}
// 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
// @Summary 创建访问规则
// @Description 管理端创建一条访问控制规则。
@@ -192,7 +229,7 @@ func (s *Server) batchAPIKeyAccessRules(w http.ResponseWriter, r *http.Request)
return
}
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
}
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, &registered)
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 404KEY 排除 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("scopeimage 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
}
+4 -61
View File
@@ -13,6 +13,7 @@ import (
"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/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/runner"
"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 502 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/models [get]
// @Router /api/v1/platform-models [get]
// @Router /api/v1/playground/models [get]
func (s *Server) listPlayableModels(w http.ResponseWriter, r *http.Request) {
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 {
if user == nil || strings.TrimSpace(user.APIKeyID) == "" || len(user.APIKeyScopes) == 0 {
if user == nil || strings.TrimSpace(user.APIKeyID) == "" {
return true
}
required := scopeForTaskKind(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
return modelaccess.ScopeAllowsTask(user.APIKeyScopes, kind)
}
func requestModelName(body map[string]any) string {
@@ -1687,33 +1657,6 @@ func modelNameFromValue(value any) string {
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 {
switch {
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-modelsOpenAI 客户端使用 /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"`
}
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 {
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)
}
for _, prefix := range legacyPrefixes {
if route == "/v1/models" {
continue
}
if strings.HasPrefix(route, prefix) {
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/upscales",
"/api/v1/pricing/estimate",
"/v1/models",
"/api/workspace/token-usage/daily",
"/api/v1/models/{model}:generateContent",
"/api/v1/videos/omni-video",
+4 -1
View File
@@ -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("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/{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}/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)))
@@ -296,8 +297,10 @@ func NewServerWithStores(
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/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 /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/model-rate-limits", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listModelRateLimitStatuses)))
mux.Handle("POST /api/v1/chat/completions", server.requireProtocolUser(clients.ProtocolOpenAIChatCompletions, server.createAPIV1ChatCompletions()))
+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)
}
})
}
}
+507
View File
@@ -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)
}
}
}
+25 -55
View File
@@ -244,7 +244,7 @@ SELECT EXISTS (
if !exists {
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
}
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) {
if len(candidates) == 0 {
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
return s.filterRuntimeCandidatesByLayeredAccess(ctx, user, candidates)
}
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 {
return nil, err
}
models, err := s.ListModels(ctx)
models, _, err := s.enabledPlatformModels(ctx)
if err != nil {
return nil, err
}
platforms, err := s.ListPlatforms(ctx)
if err != nil {
return nil, err
if excludedSubjectTypes["api_key"] {
return s.filterPlatformModelsByBaselineAccess(ctx, accessUser, models)
}
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 s.filterPlatformModelsByAccessRules(ctx, accessUser, enabled, excludedSubjectTypes)
return s.filterPlatformModelsByLayeredAccess(ctx, accessUser, models)
}
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)
if len(resources) == 0 {
return nil
}
allowed, err := s.accessibleAccessRuleResources(ctx, user)
allowed, err := s.accessibleAccessRuleResources(ctx, user, apiKeyID)
if err != nil {
return err
}
@@ -342,8 +304,8 @@ func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user
return nil
}
func (s *Store) accessibleAccessRuleResources(ctx context.Context, user *auth.User) (map[string]bool, error) {
models, err := s.ListAPIKeyAssignablePlatformModels(ctx, user)
func (s *Store) accessibleAccessRuleResources(ctx context.Context, user *auth.User, apiKeyID string) (map[string]bool, error) {
models, _, err := s.ListAPIKeyAssignablePlatformModelsForKey(ctx, user, apiKeyID)
if err != nil {
return nil, err
}
@@ -368,25 +330,28 @@ func (s *Store) resolveCurrentAccessUser(ctx context.Context, user *auth.User) (
}
next := *user
var userGroupID string
var userGroupKey string
var err error
if strings.TrimSpace(user.APIKeyID) != "" {
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
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
AND k.id = $2::uuid
AND u.status = 'active'
AND u.deleted_at IS NULL
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 {
err = s.pool.QueryRow(ctx, `
SELECT COALESCE(default_user_group_id::text, '')
FROM gateway_users
WHERE id = $1::uuid
AND status = 'active'
AND deleted_at IS NULL`, gatewayUserID).Scan(&userGroupID)
SELECT COALESCE(u.default_user_group_id::text, ''), COALESCE(g.group_key, '')
FROM gateway_users u
LEFT JOIN gateway_user_groups g ON g.id = u.default_user_group_id
WHERE u.id = $1::uuid
AND u.status = 'active'
AND u.deleted_at IS NULL`, gatewayUserID).Scan(&userGroupID, &userGroupKey)
}
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -395,6 +360,11 @@ WHERE id = $1::uuid
return nil, err
}
next.UserGroupID = userGroupID
next.UserGroupKey = userGroupKey
next.UserGroupKeys = nil
if userGroupKey != "" {
next.UserGroupKeys = []string{userGroupKey}
}
return &next, nil
}