fix(access): 修正 API Key 可分配模型范围
This commit is contained in:
@@ -58,6 +58,32 @@ func (s *Server) listAPIKeyAccessRules(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
// @Summary 创建访问规则
|
||||
// @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, ®isterResponse)
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -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("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("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)))
|
||||
|
||||
@@ -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) {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -307,7 +322,7 @@ func (s *Store) ListAccessiblePlatformModels(ctx context.Context, user *auth.Use
|
||||
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 {
|
||||
@@ -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) {
|
||||
models, err := s.ListAccessiblePlatformModels(ctx, user)
|
||||
models, err := s.ListAPIKeyAssignablePlatformModels(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -383,7 +398,12 @@ WHERE id = $1::uuid
|
||||
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 {
|
||||
return models, nil
|
||||
}
|
||||
@@ -398,6 +418,12 @@ func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *aut
|
||||
if len(rules) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
if len(excludedSubjectTypes) > 0 {
|
||||
rules = filterAccessRulesBySubjectType(rules, excludedSubjectTypes)
|
||||
if len(rules) == 0 {
|
||||
return models, nil
|
||||
}
|
||||
}
|
||||
subjects := accessRuleSubjects(user)
|
||||
level := 0
|
||||
if user != nil {
|
||||
@@ -412,6 +438,17 @@ func (s *Store) filterPlatformModelsByAccessRules(ctx context.Context, user *aut
|
||||
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) {
|
||||
values := make([]string, 0, len(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
@@ -69,6 +69,7 @@ import {
|
||||
listAccessRules,
|
||||
listAuditLogs,
|
||||
listApiKeyAccessRules,
|
||||
listApiKeyAssignableModels,
|
||||
listApiKeys,
|
||||
listBaseModels,
|
||||
listCatalogProviders,
|
||||
@@ -176,6 +177,7 @@ type DataKey =
|
||||
| 'publicCatalog'
|
||||
| 'playgroundApiKeys'
|
||||
| 'playgroundModels'
|
||||
| 'apiKeyPolicyModels'
|
||||
| 'modelCatalog'
|
||||
| 'networkProxyConfig'
|
||||
| 'clientCustomizationSettings'
|
||||
@@ -227,6 +229,7 @@ export function App() {
|
||||
summary: { modelCount: 0, sourceCount: 0 },
|
||||
});
|
||||
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
|
||||
const [apiKeyPolicyModels, setApiKeyPolicyModels] = useState<PlatformModel[]>([]);
|
||||
const [networkProxyConfig, setNetworkProxyConfig] = useState<GatewayNetworkProxyConfig | null>(null);
|
||||
const [clientCustomizationSettings, setClientCustomizationSettings] = useState<ClientCustomizationSettings | null>(null);
|
||||
const [fileStorageChannels, setFileStorageChannels] = useState<FileStorageChannel[]>([]);
|
||||
@@ -530,6 +533,9 @@ export function App() {
|
||||
case 'playgroundModels':
|
||||
setPlaygroundModels((await listPlayableModels(nextToken)).items);
|
||||
return;
|
||||
case 'apiKeyPolicyModels':
|
||||
setApiKeyPolicyModels((await listApiKeyAssignableModels(nextToken)).items);
|
||||
return;
|
||||
case 'playgroundApiKeys': {
|
||||
const response = await listPlayableApiKeys(nextToken);
|
||||
setApiKeys(response.items);
|
||||
@@ -687,7 +693,7 @@ export function App() {
|
||||
const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings);
|
||||
setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]);
|
||||
setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]);
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(input.platformId
|
||||
? `平台已更新,当前绑定 ${input.models.length} 个模型。`
|
||||
@@ -707,7 +713,7 @@ export function App() {
|
||||
const updated = await updatePlatform(token, platform.id, input);
|
||||
const platformForState = withCredentialPreviewFallback(updated, input, platform);
|
||||
setPlatforms((current) => current.map((item) => item.id === platform.id ? platformForState : item));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(status === 'enabled' ? '平台已启用。' : '平台已禁用。');
|
||||
} catch (err) {
|
||||
@@ -739,7 +745,7 @@ export function App() {
|
||||
platformPriority: state.priority,
|
||||
}
|
||||
: status));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(input.reset ? '平台动态优先级已重置。' : '平台动态优先级已更新。');
|
||||
} catch (err) {
|
||||
@@ -783,7 +789,7 @@ export function App() {
|
||||
cooldownUntil: undefined,
|
||||
}
|
||||
: model));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'models', 'platforms', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('模型运行状态已恢复。');
|
||||
} catch (err) {
|
||||
@@ -800,7 +806,7 @@ export function App() {
|
||||
await deletePlatform(token, platformId);
|
||||
setPlatforms((current) => current.filter((item) => item.id !== platformId));
|
||||
setModels((current) => current.filter((item) => item.platformId !== platformId));
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits');
|
||||
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('平台已删除。');
|
||||
} catch (err) {
|
||||
@@ -816,6 +822,7 @@ export function App() {
|
||||
try {
|
||||
const item = tenantId ? await updateTenant(token, tenantId, input) : await createTenant(token, input);
|
||||
setTenants((current) => [item, ...current.filter((tenant) => tenant.id !== item.id)]);
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(tenantId ? '租户已更新。' : '租户已创建。');
|
||||
} catch (err) {
|
||||
@@ -831,6 +838,7 @@ export function App() {
|
||||
try {
|
||||
await deleteTenant(token, tenantId);
|
||||
setTenants((current) => current.filter((tenant) => tenant.id !== tenantId));
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('租户已删除。');
|
||||
} catch (err) {
|
||||
@@ -846,7 +854,7 @@ export function App() {
|
||||
try {
|
||||
const item = userId ? await updateGatewayUser(token, userId, input) : await createGatewayUser(token, input);
|
||||
setUsers((current) => [item, ...current.filter((user) => user.id !== item.id)]);
|
||||
invalidateDataKeys('playgroundModels');
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(userId ? '用户已更新。' : '用户已创建。');
|
||||
} catch (err) {
|
||||
@@ -902,7 +910,7 @@ export function App() {
|
||||
try {
|
||||
const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input);
|
||||
setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]);
|
||||
invalidateDataKeys('modelCatalog');
|
||||
invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。');
|
||||
} catch (err) {
|
||||
@@ -920,8 +928,7 @@ export function App() {
|
||||
setUserGroups((current) => current.filter((group) => group.id !== groupId));
|
||||
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
|
||||
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
|
||||
invalidateDataKeys('modelCatalog');
|
||||
invalidateDataKeys('playgroundModels');
|
||||
invalidateDataKeys('modelCatalog', 'playgroundModels', 'apiKeyPolicyModels');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('用户组已删除。');
|
||||
} catch (err) {
|
||||
@@ -975,7 +982,7 @@ export function App() {
|
||||
try {
|
||||
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
|
||||
setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
|
||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
||||
setCoreState('ready');
|
||||
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
|
||||
} catch (err) {
|
||||
@@ -991,7 +998,7 @@ export function App() {
|
||||
try {
|
||||
await deleteAccessRule(token, ruleId);
|
||||
setAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
|
||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('访问权限规则已删除。');
|
||||
} catch (err) {
|
||||
@@ -1007,7 +1014,7 @@ export function App() {
|
||||
try {
|
||||
const response = await batchAccessRules(token, input);
|
||||
setAccessRules(response.items);
|
||||
invalidateDataKeys('playgroundModels', 'modelCatalog');
|
||||
invalidateDataKeys('playgroundModels', 'apiKeyPolicyModels', 'modelCatalog');
|
||||
setCoreState('ready');
|
||||
setCoreMessage('访问权限已更新。');
|
||||
} catch (err) {
|
||||
@@ -1355,7 +1362,7 @@ export function App() {
|
||||
apiKeyForm={apiKeyForm}
|
||||
apiKeySecret={apiKeySecret}
|
||||
apiKeySecretsById={apiKeySecretsById}
|
||||
apiKeyPolicyModels={playgroundModels}
|
||||
apiKeyPolicyModels={apiKeyPolicyModels}
|
||||
data={data}
|
||||
message={coreMessage}
|
||||
section={workspaceSection}
|
||||
@@ -1622,7 +1629,7 @@ function dataKeysForRoute(
|
||||
if (activePage === 'workspace') {
|
||||
if (workspaceSection === 'overview') return ['currentUser', 'currentUserGroups', 'apiKeys'];
|
||||
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 === 'transactions') return ['wallet', 'walletTransactions'];
|
||||
return [];
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getCurrentUser,
|
||||
getOpsManagementSkillMetadata,
|
||||
loginLocalAccount,
|
||||
listApiKeyAssignableModels,
|
||||
OIDC_BROWSER_SESSION_CREDENTIAL,
|
||||
startIdentityPairing,
|
||||
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', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
@@ -459,6 +459,10 @@ export async function listApiKeyAccessRules(token: string): Promise<ListResponse
|
||||
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> {
|
||||
return request<GatewayAccessRule>('/api/admin/access-rules', {
|
||||
body: input,
|
||||
|
||||
Reference in New Issue
Block a user