diff --git a/apps/api/internal/httpapi/access_rule_handlers.go b/apps/api/internal/httpapi/access_rule_handlers.go index 9601608..7cd20ef 100644 --- a/apps/api/internal/httpapi/access_rule_handlers.go +++ b/apps/api/internal/httpapi/access_rule_handlers.go @@ -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 管理端创建一条访问控制规则。 diff --git a/apps/api/internal/httpapi/api_key_assignable_models_integration_test.go b/apps/api/internal/httpapi/api_key_assignable_models_integration_test.go new file mode 100644 index 0000000..0edf1b6 --- /dev/null +++ b/apps/api/internal/httpapi/api_key_assignable_models_integration_test.go @@ -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() +} diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index 7834444..6c9f010 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -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))) diff --git a/apps/api/internal/store/access_rules.go b/apps/api/internal/store/access_rules.go index f30a23c..3eaf1ba 100644 --- a/apps/api/internal/store/access_rules.go +++ b/apps/api/internal/store/access_rules.go @@ -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 { diff --git a/apps/api/internal/store/access_rules_test.go b/apps/api/internal/store/access_rules_test.go new file mode 100644 index 0000000..6458c82 --- /dev/null +++ b/apps/api/internal/store/access_rules_test.go @@ -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) + } + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6870008..0877d36 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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([]); + const [apiKeyPolicyModels, setApiKeyPolicyModels] = useState([]); const [networkProxyConfig, setNetworkProxyConfig] = useState(null); const [clientCustomizationSettings, setClientCustomizationSettings] = useState(null); const [fileStorageChannels, setFileStorageChannels] = useState([]); @@ -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 []; diff --git a/apps/web/src/api.test.ts b/apps/web/src/api.test.ts index 470cc3b..0aa6b2e 100644 --- a/apps/web/src/api.test.ts +++ b/apps/web/src/api.test.ts @@ -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(); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index aa79f36..30ba323 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -459,6 +459,10 @@ export async function listApiKeyAccessRules(token: string): Promise>('/api/v1/api-keys/access-rules', { token }); } +export async function listApiKeyAssignableModels(token: string): Promise> { + return request>('/api/v1/api-keys/assignable-models', { token }); +} + export async function createAccessRule(token: string, input: GatewayAccessRuleUpsertRequest): Promise { return request('/api/admin/access-rules', { body: input,