fix(access): 修正 API Key 可分配模型范围

This commit is contained in:
2026-07-22 00:24:36 +08:00
parent 276c0612d8
commit 56d4a3a6b7
8 changed files with 286 additions and 17 deletions
@@ -58,6 +58,32 @@ func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": items}) writeJSON(w, http.StatusOK, map[string]any{"items": items})
} }
// listAPIKeyAssignableModels godoc
// @Summary 列出 API Key 可分配模型
// @Description 按当前用户自身的用户、租户和用户组权限返回可分配给 API Key 的启用模型,不受任何 API Key 权限规则影响。
// @Tags api-keys
// @Produce json
// @Security BearerAuth
// @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) {
user, _ := auth.UserFromContext(r.Context())
models, err := s.store.ListAPIKeyAssignablePlatformModels(r.Context(), user)
if err != nil {
if errors.Is(err, store.ErrLocalUserRequired) {
writeLocalUserRequired(w)
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, map[string]any{"items": s.platformModelResponses(r.Context(), models)})
}
// createAccessRule godoc // createAccessRule godoc
// @Summary 创建访问规则 // @Summary 创建访问规则
// @Description 管理端创建一条访问控制规则。 // @Description 管理端创建一条访问控制规则。
@@ -0,0 +1,150 @@
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"
)
func TestAPIKeyAssignableModelsIgnoreAPIKeyRules(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 assignable-model 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()
suffixText := strconv.FormatInt(time.Now().UnixNano(), 10)
username := "api_key_assignable_" + suffixText
password := "password123"
var registerResponse 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, &registerResponse)
testPool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatalf("connect test pool: %v", err)
}
defer testPool.Close()
if _, err := testPool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
t.Fatalf("promote test user: %v", err)
}
var loginResponse struct {
AccessToken string `json:"accessToken"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"account": username,
"password": password,
}, http.StatusOK, &loginResponse)
createAPIKey := func(name string) string {
t.Helper()
var response struct {
APIKey struct {
ID string `json:"id"`
} `json:"apiKey"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", loginResponse.AccessToken, map[string]any{
"name": name,
}, http.StatusCreated, &response)
return response.APIKey.ID
}
firstAPIKeyID := createAPIKey("first assignable key")
secondAPIKeyID := createAPIKey("second assignable key")
var platform struct {
ID string `json:"id"`
}
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
"provider": "openai",
"platformKey": "api-key-assignable-" + suffixText,
"name": "API Key Assignable Test",
"baseUrl": "https://api.openai.com/v1",
"authType": "bearer",
"credentials": map[string]any{"mode": "simulation"},
"config": map[string]any{"testMode": true},
}, http.StatusCreated, &platform)
var model struct {
ID string `json:"id"`
}
modelName := "api-key-assignable-model-" + suffixText
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", loginResponse.AccessToken, map[string]any{
"canonicalModelKey": "openai:gpt-4o-mini",
"modelName": modelName,
"modelAlias": modelName,
"modelType": []string{"text_generate"},
"displayName": "API Key Assignable Model",
}, http.StatusCreated, &model)
assertAssignable := func() {
t.Helper()
var response struct {
Items []struct {
ID string `json:"id"`
ModelName string `json:"modelName"`
} `json:"items"`
}
doJSON(t, server.URL, http.MethodGet, "/api/v1/api-keys/assignable-models", loginResponse.AccessToken, nil, http.StatusOK, &response)
if !modelListContains(response.Items, model.ID) {
t.Fatalf("user-owned model should remain assignable regardless of API key rules: %+v", response.Items)
}
}
assignModel := func(apiKeyID string) {
t.Helper()
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys/access-rules/batch", loginResponse.AccessToken, map[string]any{
"subjectType": "api_key",
"subjectId": apiKeyID,
"effect": "allow",
"upsertResources": []map[string]any{{
"resourceType": "platform_model",
"resourceId": model.ID,
"priority": 100,
"minPermissionLevel": 0,
"status": "active",
}},
"deleteResources": []map[string]any{},
}, http.StatusOK, nil)
}
assertAssignable()
assignModel(firstAPIKeyID)
assertAssignable()
assignModel(secondAPIKeyID)
assertAssignable()
}
+1
View File
@@ -182,6 +182,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("POST /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey))) mux.Handle("POST /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey)))
mux.Handle("GET /api/v1/api-keys/access-rules", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules))) 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("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("PATCH /api/v1/api-keys/{apiKeyID}/scopes", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.updateAPIKeyScopes))) 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("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))) mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey)))
+40 -3
View File
@@ -283,6 +283,21 @@ func (s *Store) filterCandidatesByAccessRules(ctx context.Context, user *auth.Us
} }
func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) { func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) {
return s.listPlatformModelsForAccessRules(ctx, user, nil)
}
// ListAPIKeyAssignablePlatformModels returns the enabled models that the
// current user may delegate to their API keys. API-key rules are deliberately
// excluded here: they restrict individual credentials and must not shrink the
// resource pool that the owning user can manage.
func (s *Store) ListAPIKeyAssignablePlatformModels(ctx context.Context, user *auth.User) ([]PlatformModel, error) {
if localGatewayUserID(user) == "" {
return nil, ErrLocalUserRequired
}
return s.listPlatformModelsForAccessRules(ctx, user, map[string]bool{"api_key": true})
}
func (s *Store) listPlatformModelsForAccessRules(ctx context.Context, user *auth.User, excludedSubjectTypes map[string]bool) ([]PlatformModel, error) {
accessUser, err := s.resolveCurrentAccessUser(ctx, user) accessUser, err := s.resolveCurrentAccessUser(ctx, user)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -307,7 +322,7 @@ func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.Use
enabled = append(enabled, model) enabled = append(enabled, model)
} }
} }
return s.filterPlatformModelsByAccessRules(ctx, accessUser, enabled) return s.filterPlatformModelsByAccessRules(ctx, accessUser, enabled, excludedSubjectTypes)
} }
func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user *auth.User, resources []AccessRuleResourceInput) error { func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user *auth.User, resources []AccessRuleResourceInput) error {
@@ -328,7 +343,7 @@ func (s *Store) ensureAPIKeyAccessRuleResourcesAllowed(ctx context.Context, user
} }
func (s *Store) accessibleAccessRuleResources(ctx context.Context, user *auth.User) (map[string]bool, error) { func (s *Store) accessibleAccessRuleResources(ctx context.Context, user *auth.User) (map[string]bool, error) {
models, err := s.ListAccessiblePlatformModels(ctx, user) models, err := s.ListAPIKeyAssignablePlatformModels(ctx, user)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -383,7 +398,12 @@ WHERE id = $1::uuid
return &next, nil return &next, nil
} }
func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *auth.User, models []PlatformModel) ([]PlatformModel, error) { func (s *Store) filterPlatformModelsByAccessRules(
ctx context.Context,
user *auth.User,
models []PlatformModel,
excludedSubjectTypes map[string]bool,
) ([]PlatformModel, error) {
if len(models) == 0 { if len(models) == 0 {
return models, nil return models, nil
} }
@@ -398,6 +418,12 @@ func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *aut
if len(rules) == 0 { if len(rules) == 0 {
return models, nil return models, nil
} }
if len(excludedSubjectTypes) > 0 {
rules = filterAccessRulesBySubjectType(rules, excludedSubjectTypes)
if len(rules) == 0 {
return models, nil
}
}
subjects := accessRuleSubjects(user) subjects := accessRuleSubjects(user)
level := 0 level := 0
if user != nil { if user != nil {
@@ -412,6 +438,17 @@ func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *aut
return filtered, nil return filtered, nil
} }
func filterAccessRulesBySubjectType(rules []AccessRule, excludedSubjectTypes map[string]bool) []AccessRule {
filtered := make([]AccessRule, 0, len(rules))
for _, rule := range rules {
if excludedSubjectTypes[rule.SubjectType] {
continue
}
filtered = append(filtered, rule)
}
return filtered
}
func (s *Store) listActiveAccessRulesForResources(ctx context.Context, resources []accessRuleResource) ([]AccessRule, error) { func (s *Store) listActiveAccessRulesForResources(ctx context.Context, resources []accessRuleResource) ([]AccessRule, error) {
values := make([]string, 0, len(resources)) values := make([]string, 0, len(resources))
for _, resource := range resources { for _, resource := range resources {
@@ -0,0 +1,23 @@
package store
import "testing"
func TestFilterAccessRulesBySubjectTypeExcludesAPIKeyRulesOnly(t *testing.T) {
rules := []AccessRule{
{ID: "api-key-allow", SubjectType: "api_key", Effect: "allow"},
{ID: "api-key-deny", SubjectType: "api_key", Effect: "deny"},
{ID: "user-group-allow", SubjectType: "user_group", Effect: "allow"},
{ID: "user-deny", SubjectType: "user", Effect: "deny"},
{ID: "tenant-allow", SubjectType: "tenant", Effect: "allow"},
}
filtered := filterAccessRulesBySubjectType(rules, map[string]bool{"api_key": true})
if len(filtered) != 3 {
t.Fatalf("filtered rule count = %d, want 3: %+v", len(filtered), filtered)
}
for _, rule := range filtered {
if rule.SubjectType == "api_key" {
t.Fatalf("api-key rule should not affect the owning user's assignable resources: %+v", rule)
}
}
}
+21 -14
View File
@@ -69,6 +69,7 @@ import {
listAccessRules, listAccessRules,
listAuditLogs, listAuditLogs,
listApiKeyAccessRules, listApiKeyAccessRules,
listApiKeyAssignableModels,
listApiKeys, listApiKeys,
listBaseModels, listBaseModels,
listCatalogProviders, listCatalogProviders,
@@ -176,6 +177,7 @@ type DataKey =
| 'publicCatalog' | 'publicCatalog'
| 'playgroundApiKeys' | 'playgroundApiKeys'
| 'playgroundModels' | 'playgroundModels'
| 'apiKeyPolicyModels'
| 'modelCatalog' | 'modelCatalog'
| 'networkProxyConfig' | 'networkProxyConfig'
| 'clientCustomizationSettings' | 'clientCustomizationSettings'
@@ -227,6 +229,7 @@ export function App() {
summary: { modelCount: 0, sourceCount: 0 }, summary: { modelCount: 0, sourceCount: 0 },
}); });
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]); const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
const [apiKeyPolicyModels, setApiKeyPolicyModels] = useState<PlatformModel[]>([]);
const [networkProxyConfig, setNetworkProxyConfig] = useState<GatewayNetworkProxyConfig | null>(null); const [networkProxyConfig, setNetworkProxyConfig] = useState<GatewayNetworkProxyConfig | null>(null);
const [clientCustomizationSettings, setClientCustomizationSettings] = useState<ClientCustomizationSettings | null>(null); const [clientCustomizationSettings, setClientCustomizationSettings] = useState<ClientCustomizationSettings | null>(null);
const [fileStorageChannels, setFileStorageChannels] = useState<FileStorageChannel[]>([]); const [fileStorageChannels, setFileStorageChannels] = useState<FileStorageChannel[]>([]);
@@ -530,6 +533,9 @@ export function App() {
case 'playgroundModels': case 'playgroundModels':
setPlaygroundModels((await listPlayableModels(nextToken)).items); setPlaygroundModels((await listPlayableModels(nextToken)).items);
return; return;
case 'apiKeyPolicyModels':
setApiKeyPolicyModels((await listApiKeyAssignableModels(nextToken)).items);
return;
case 'playgroundApiKeys': { case 'playgroundApiKeys': {
const response = await listPlayableApiKeys(nextToken); const response = await listPlayableApiKeys(nextToken);
setApiKeys(response.items); setApiKeys(response.items);
@@ -687,7 +693,7 @@ export function App() {
const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings); const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings);
setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]); setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]);
setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]); setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]);
invalidateDataKeys('modelCatalog', 'modelRateLimits'); invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
setCoreState('ready'); setCoreState('ready');
setCoreMessage(input.platformId setCoreMessage(input.platformId
? `平台已更新,当前绑定 ${input.models.length} 个模型。` ? `平台已更新,当前绑定 ${input.models.length} 个模型。`
@@ -707,7 +713,7 @@ export function App() {
const updated = await updatePlatform(token, platform.id, input); const updated = await updatePlatform(token, platform.id, input);
const platformForState = withCredentialPreviewFallback(updated, input, platform); const platformForState = withCredentialPreviewFallback(updated, input, platform);
setPlatforms((current) => current.map((item) => item.id === platform.id ? platformForState : item)); setPlatforms((current) => current.map((item) => item.id === platform.id ? platformForState : item));
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels'); invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
setCoreState('ready'); setCoreState('ready');
setCoreMessage(status === 'enabled' ? '平台已启用。' : '平台已禁用。'); setCoreMessage(status === 'enabled' ? '平台已启用。' : '平台已禁用。');
} catch (err) { } catch (err) {
@@ -739,7 +745,7 @@ export function App() {
platformPriority: state.priority, platformPriority: state.priority,
} }
: status)); : status));
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels'); invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels', 'apiKeyPolicyModels');
setCoreState('ready'); setCoreState('ready');
setCoreMessage(input.reset ? '平台动态优先级已重置。' : '平台动态优先级已更新。'); setCoreMessage(input.reset ? '平台动态优先级已重置。' : '平台动态优先级已更新。');
} catch (err) { } catch (err) {
@@ -783,7 +789,7 @@ export function App() {
cooldownUntil: undefined, cooldownUntil: undefined,
} }
: model)); : model));
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels'); invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels', 'apiKeyPolicyModels');
setCoreState('ready'); setCoreState('ready');
setCoreMessage('模型运行状态已恢复。'); setCoreMessage('模型运行状态已恢复。');
} catch (err) { } catch (err) {
@@ -800,7 +806,7 @@ export function App() {
await deletePlatform(token, platformId); await deletePlatform(token, platformId);
setPlatforms((current) => current.filter((item) => item.id !== platformId)); setPlatforms((current) => current.filter((item) => item.id !== platformId));
setModels((current) => current.filter((item) => item.platformId !== platformId)); setModels((current) => current.filter((item) => item.platformId !== platformId));
invalidateDataKeys('modelCatalog', 'modelRateLimits'); invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
setCoreState('ready'); setCoreState('ready');
setCoreMessage('平台已删除。'); setCoreMessage('平台已删除。');
} catch (err) { } catch (err) {
@@ -816,6 +822,7 @@ export function App() {
try { try {
const item = tenantId ? await updateTenant(token, tenantId, input) : await createTenant(token, input); const item = tenantId ? await updateTenant(token, tenantId, input) : await createTenant(token, input);
setTenants((current) => [item, ...current.filter((tenant) => tenant.id !== item.id)]); setTenants((current) => [item, ...current.filter((tenant) => tenant.id !== item.id)]);
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
setCoreState('ready'); setCoreState('ready');
setCoreMessage(tenantId ? '租户已更新。' : '租户已创建。'); setCoreMessage(tenantId ? '租户已更新。' : '租户已创建。');
} catch (err) { } catch (err) {
@@ -831,6 +838,7 @@ export function App() {
try { try {
await deleteTenant(token, tenantId); await deleteTenant(token, tenantId);
setTenants((current) => current.filter((tenant) => tenant.id !== tenantId)); setTenants((current) => current.filter((tenant) => tenant.id !== tenantId));
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
setCoreState('ready'); setCoreState('ready');
setCoreMessage('租户已删除。'); setCoreMessage('租户已删除。');
} catch (err) { } catch (err) {
@@ -846,7 +854,7 @@ export function App() {
try { try {
const item = userId ? await updateGatewayUser(token, userId, input) : await createGatewayUser(token, input); const item = userId ? await updateGatewayUser(token, userId, input) : await createGatewayUser(token, input);
setUsers((current) => [item, ...current.filter((user) => user.id !== item.id)]); setUsers((current) => [item, ...current.filter((user) => user.id !== item.id)]);
invalidateDataKeys('playgroundModels'); invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
setCoreState('ready'); setCoreState('ready');
setCoreMessage(userId ? '用户已更新。' : '用户已创建。'); setCoreMessage(userId ? '用户已更新。' : '用户已创建。');
} catch (err) { } catch (err) {
@@ -902,7 +910,7 @@ export function App() {
try { try {
const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input); const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input);
setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]); setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]);
invalidateDataKeys('modelCatalog'); invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels');
setCoreState('ready'); setCoreState('ready');
setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。'); setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。');
} catch (err) { } catch (err) {
@@ -920,8 +928,7 @@ export function App() {
setUserGroups((current) => current.filter((group) => group.id !== groupId)); setUserGroups((current) => current.filter((group) => group.id !== groupId));
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant)); setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user)); setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
invalidateDataKeys('modelCatalog'); invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels');
invalidateDataKeys('playgroundModels');
setCoreState('ready'); setCoreState('ready');
setCoreMessage('用户组已删除。'); setCoreMessage('用户组已删除。');
} catch (err) { } catch (err) {
@@ -975,7 +982,7 @@ export function App() {
try { try {
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input); const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]); setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
invalidateDataKeys('playgroundModels', 'modelCatalog'); invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
setCoreState('ready'); setCoreState('ready');
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。'); setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
} catch (err) { } catch (err) {
@@ -991,7 +998,7 @@ export function App() {
try { try {
await deleteAccessRule(token, ruleId); await deleteAccessRule(token, ruleId);
setAccessRules((current) => current.filter((rule) => rule.id !== ruleId)); setAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
invalidateDataKeys('playgroundModels', 'modelCatalog'); invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
setCoreState('ready'); setCoreState('ready');
setCoreMessage('访问权限规则已删除。'); setCoreMessage('访问权限规则已删除。');
} catch (err) { } catch (err) {
@@ -1007,7 +1014,7 @@ export function App() {
try { try {
const response = await batchAccessRules(token, input); const response = await batchAccessRules(token, input);
setAccessRules(response.items); setAccessRules(response.items);
invalidateDataKeys('playgroundModels', 'modelCatalog'); invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
setCoreState('ready'); setCoreState('ready');
setCoreMessage('访问权限已更新。'); setCoreMessage('访问权限已更新。');
} catch (err) { } catch (err) {
@@ -1355,7 +1362,7 @@ export function App() {
apiKeyForm={apiKeyForm} apiKeyForm={apiKeyForm}
apiKeySecret={apiKeySecret} apiKeySecret={apiKeySecret}
apiKeySecretsById={apiKeySecretsById} apiKeySecretsById={apiKeySecretsById}
apiKeyPolicyModels={playgroundModels} apiKeyPolicyModels={apiKeyPolicyModels}
data={data} data={data}
message={coreMessage} message={coreMessage}
section={workspaceSection} section={workspaceSection}
@@ -1622,7 +1629,7 @@ function dataKeysForRoute(
if (activePage === 'workspace') { if (activePage === 'workspace') {
if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys']; if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys'];
if (workspaceSection === 'billing') return ['wallet']; if (workspaceSection === 'billing') return ['wallet'];
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'playgroundModels']; if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'apiKeyPolicyModels'];
if (workspaceSection === 'tasks') return ['tasks']; if (workspaceSection === 'tasks') return ['tasks'];
if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions']; if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions'];
return []; return [];
+21
View File
@@ -10,6 +10,7 @@ import {
getCurrentUser, getCurrentUser,
getOpsManagementSkillMetadata, getOpsManagementSkillMetadata,
loginLocalAccount, loginLocalAccount,
listApiKeyAssignableModels,
OIDC_BROWSER_SESSION_CREDENTIAL, OIDC_BROWSER_SESSION_CREDENTIAL,
startIdentityPairing, startIdentityPairing,
retireIdentityPairingSecurityEventConflict, retireIdentityPairingSecurityEventConflict,
@@ -231,6 +232,26 @@ describe('OIDC browser session transport', () => {
}); });
}); });
describe('API Key permission resources', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('loads the user-owned resource pool independently from playable models', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
await listApiKeyAssignableModels('user-token');
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toContain('/api/v1/api-keys/assignable-models');
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer user-token');
});
});
describe('Public Agent resources', () => { describe('Public Agent resources', () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
+4
View File
@@ -459,6 +459,10 @@ export async function listApiKeyAccessRules(token: string): Promise<ListResponse
return request<ListResponse<GatewayAccessRule>>('/api/v1/api-keys/access-rules', { token }); return request<ListResponse<GatewayAccessRule>>('/api/v1/api-keys/access-rules', { token });
} }
export async function listApiKeyAssignableModels(token: string): Promise<ListResponse<PlatformModel>> {
return request<ListResponse<PlatformModel>>('/api/v1/api-keys/assignable-models', { token });
}
export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise<GatewayAccessRule> { export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise<GatewayAccessRule> {
return request<GatewayAccessRule>('/api/admin/access-rules', { return request<GatewayAccessRule>('/api/admin/access-rules', {
body: input, body: input,