Skip to content

Commit f9b5bf9

Browse files
Harden account group routing controls
1 parent 0ae576b commit f9b5bf9

10 files changed

Lines changed: 392 additions & 12 deletions

File tree

admin/account_groups.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,37 @@ func (h *Handler) DeleteAccountGroup(c *gin.Context) {
201201
h.store.ApplyAccountGroups(acc.DBID, groups)
202202
}
203203
}
204+
if err := h.removeDeletedGroupFromAPIKeyScopes(ctx, id); err != nil {
205+
writeInternalError(c, err)
206+
return
207+
}
204208
writeMessage(c, http.StatusOK, "分组已删除")
205209
}
206210

211+
func (h *Handler) removeDeletedGroupFromAPIKeyScopes(ctx context.Context, groupID int64) error {
212+
if h == nil || h.db == nil || groupID <= 0 {
213+
return nil
214+
}
215+
keys, err := h.db.ListAPIKeys(ctx)
216+
if err != nil {
217+
return err
218+
}
219+
for _, key := range keys {
220+
if key == nil || !containsInt64(key.AllowedGroupIDs, groupID) {
221+
continue
222+
}
223+
next := removeInt64(key.AllowedGroupIDs, groupID)
224+
if err := h.db.UpdateAPIKeyAllowedGroupIDs(ctx, key.ID, next); err != nil {
225+
return err
226+
}
227+
if h.store != nil {
228+
h.store.SetAPIKeyAllowedGroups(key.ID, next)
229+
}
230+
h.invalidateAPIKeyRuntimeCaches(ctx, key.Key)
231+
}
232+
return nil
233+
}
234+
207235
func sanitizeAccountGroupName(raw string) (string, error) {
208236
name := strings.TrimSpace(raw)
209237
if name == "" {
@@ -230,6 +258,15 @@ func removeInt64(slice []int64, target int64) []int64 {
230258
return out
231259
}
232260

261+
func containsInt64(slice []int64, target int64) bool {
262+
for _, v := range slice {
263+
if v == target {
264+
return true
265+
}
266+
}
267+
return false
268+
}
269+
233270
func dedupeInt64(ids []int64) []int64 {
234271
if len(ids) == 0 {
235272
return nil

admin/handler.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3361,6 +3361,9 @@ func (h *Handler) DeleteAPIKey(c *gin.Context) {
33613361
writeError(c, http.StatusInternalServerError, "删除失败: "+err.Error())
33623362
return
33633363
}
3364+
if h.store != nil {
3365+
h.store.SetAPIKeyAllowedGroups(id, nil)
3366+
}
33643367
h.invalidateAPIKeyRuntimeCaches(ctx, keyToInvalidate)
33653368
writeMessage(c, http.StatusOK, "已删除")
33663369
}

auth/fast_scheduler_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,29 @@ func TestStoreNextExcludingRespectsAPIKeyWhitelist(t *testing.T) {
147147
}
148148
}
149149

150+
func TestStoreNextExcludingRespectsAPIKeyAllowedGroups(t *testing.T) {
151+
restricted := newFastSchedulerTestAccount(1, HealthTierHealthy, 120, 1)
152+
restricted.GroupIDs = []int64{10}
153+
fallback := newFastSchedulerTestAccount(2, HealthTierHealthy, 80, 1)
154+
fallback.GroupIDs = []int64{20}
155+
156+
store := &Store{
157+
accounts: []*Account{restricted, fallback},
158+
maxConcurrency: 1,
159+
}
160+
store.SetAPIKeyAllowedGroups(1, []int64{20})
161+
162+
got := store.NextExcluding(1, nil)
163+
if got == nil {
164+
t.Fatal("NextExcluding() returned nil")
165+
}
166+
defer store.Release(got)
167+
168+
if got.DBID != 2 {
169+
t.Fatalf("NextExcluding() picked dbID=%d, want 2", got.DBID)
170+
}
171+
}
172+
150173
func TestStoreNextSkipsDispatchPausedAccount(t *testing.T) {
151174
paused := newFastSchedulerTestAccount(1, HealthTierHealthy, 120, 1)
152175
atomic.StoreInt32(&paused.DispatchPaused, 1)

auth/session_affinity_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,26 @@ func TestNextForSessionFallsBackWhenAPIKeyNotAllowed(t *testing.T) {
316316
t.Fatalf("proxyURL = %q, want empty fallback proxy", proxyURL)
317317
}
318318
}
319+
320+
func TestNextForSessionFallsBackWhenAPIKeyGroupNotAllowed(t *testing.T) {
321+
store := &Store{
322+
accounts: []*Account{
323+
{DBID: 1, AccessToken: "tok-1", GroupIDs: []int64{20}},
324+
{DBID: 2, AccessToken: "tok-2", GroupIDs: []int64{10}},
325+
},
326+
maxConcurrency: 2,
327+
}
328+
store.SetAPIKeyAllowedGroups(1, []int64{20})
329+
store.bindSessionAffinity("session-1", store.accounts[1], "http://proxy-2")
330+
331+
acc, proxyURL := store.NextForSession("session-1", 1, nil)
332+
if acc == nil {
333+
t.Fatal("expected fallback account")
334+
}
335+
if acc.DBID != 1 {
336+
t.Fatalf("account DBID = %d, want %d", acc.DBID, 1)
337+
}
338+
if proxyURL != "" {
339+
t.Fatalf("proxyURL = %q, want empty fallback proxy", proxyURL)
340+
}
341+
}

auth/store.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2515,7 +2515,7 @@ func (s *Store) NextExcludingWithFilter(apiKeyID int64, exclude map[int64]bool,
25152515
if !acc.IsAvailable() {
25162516
continue
25172517
}
2518-
if !acc.AllowsAPIKey(apiKeyID) {
2518+
if !s.accountAllowedForAPIKey(acc, apiKeyID) {
25192519
continue
25202520
}
25212521
if filter != nil && !filter(acc) {
@@ -2709,7 +2709,7 @@ func (s *Store) takeByIDExcluding(id int64, apiKeyID int64, exclude map[int64]bo
27092709
if s.accountHasCachedCooldown(target) {
27102710
return nil
27112711
}
2712-
if !target.AllowsAPIKey(apiKeyID) {
2712+
if !s.accountAllowedForAPIKey(target, apiKeyID) {
27132713
return nil
27142714
}
27152715
if filter != nil && !filter(target) {
@@ -2761,7 +2761,7 @@ func (s *Store) hasDispatchCandidateWithFilter(apiKeyID int64, exclude map[int64
27612761
if s.accountHasCachedCooldown(acc) {
27622762
continue
27632763
}
2764-
if !acc.AllowsAPIKey(apiKeyID) {
2764+
if !s.accountAllowedForAPIKey(acc, apiKeyID) {
27652765
continue
27662766
}
27672767
if filter != nil && !filter(acc) {
@@ -3146,6 +3146,13 @@ func (s *Store) APIKeyAllowsAccount(apiKeyID int64, acc *Account) bool {
31463146
return false
31473147
}
31483148

3149+
func (s *Store) accountAllowedForAPIKey(acc *Account, apiKeyID int64) bool {
3150+
if acc == nil {
3151+
return false
3152+
}
3153+
return acc.AllowsAPIKey(apiKeyID) && s.APIKeyAllowsAccount(apiKeyID, acc)
3154+
}
3155+
31493156
func (s *Store) ApplyOpenAIResponsesConfig(dbID int64, baseURL, apiKey string, models []string, proxyURL string) bool {
31503157
acc := s.FindByID(dbID)
31513158
if acc == nil {

database/postgres.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -868,7 +868,7 @@ func (row *APIKeyRow) IsQuotaExhausted() bool {
868868
}
869869

870870
func (row *APIKeyRow) HasAccessConstraints() bool {
871-
return row != nil && (row.QuotaLimit > 0 || row.ExpiresAt.Valid)
871+
return row != nil && (row.QuotaLimit > 0 || row.ExpiresAt.Valid || len(row.AllowedGroupIDs) > 0)
872872
}
873873

874874
// UpdateAPIKeyName updates the display name of an API key without changing the key value.

frontend/src/locales/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,13 +404,17 @@
404404
"groupManageTitle": "Account Group Management",
405405
"groupCreate": "Create Group",
406406
"groupCreateTitle": "Create Account Group",
407+
"groupEdit": "Edit Group",
408+
"groupEditTitle": "Edit Account Group",
407409
"groupName": "Group Name",
410+
"groupNameRequired": "Enter a group name",
408411
"groupNamePlaceholder": "e.g. Pro Pool",
409412
"groupDescription": "Description",
410413
"groupDescriptionPlaceholder": "Optional description",
411414
"groupColor": "Color",
412415
"groupColorPlaceholder": "#2563eb",
413416
"groupMembers": "Members",
417+
"groupNoDescription": "No description",
414418
"groupEmpty": "No groups yet",
415419
"groupEmptyDesc": "Create groups, then assign them in account editing.",
416420
"groupCreated": "Group created",

frontend/src/locales/zh.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,13 +404,17 @@
404404
"groupManageTitle": "账号分组管理",
405405
"groupCreate": "创建分组",
406406
"groupCreateTitle": "创建账号分组",
407+
"groupEdit": "编辑分组",
408+
"groupEditTitle": "编辑账号分组",
407409
"groupName": "分组名称",
410+
"groupNameRequired": "请输入分组名称",
408411
"groupNamePlaceholder": "例如 Pro 池",
409412
"groupDescription": "分组说明",
410413
"groupDescriptionPlaceholder": "可选说明",
411414
"groupColor": "颜色",
412415
"groupColorPlaceholder": "#2563eb",
413416
"groupMembers": "成员",
417+
"groupNoDescription": "暂无说明",
414418
"groupEmpty": "暂无分组",
415419
"groupEmptyDesc": "创建分组后可在账号编辑中分配。",
416420
"groupCreated": "分组已创建",

0 commit comments

Comments
 (0)