Merge pull request #2612 from wucm667/fix/group-status-key-auth-block
fix(auth): 停用/删除分组后阻断已发放 API Key 的请求
This commit is contained in:
commit
a6db05c824
@ -80,7 +80,7 @@ func TestUserRepository_RemoveGroupFromAllowedGroups_RemovesAllOccurrences(t *te
|
||||
require.NotContains(t, u2After.AllowedGroups, targetGroup.ID)
|
||||
}
|
||||
|
||||
func TestGroupRepository_DeleteCascade_RemovesAllowedGroupsAndClearsApiKeys(t *testing.T) {
|
||||
func TestGroupRepository_DeleteCascade_PreservesApiKeyGroupID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
entClient := tx.Client()
|
||||
@ -138,8 +138,10 @@ func TestGroupRepository_DeleteCascade_RemovesAllowedGroupsAndClearsApiKeys(t *t
|
||||
require.NotContains(t, uAfter.AllowedGroups, targetGroup.ID)
|
||||
require.Contains(t, uAfter.AllowedGroups, otherGroup.ID)
|
||||
|
||||
// API keys bound to the deleted group should have group_id cleared.
|
||||
// API keys keep their group_id so auth can reject keys bound to a deleted group.
|
||||
keyAfter, err := apiKeyRepo.GetByID(ctx, key.ID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, keyAfter.GroupID)
|
||||
require.NotNil(t, keyAfter.GroupID)
|
||||
require.Equal(t, targetGroup.ID, *keyAfter.GroupID)
|
||||
require.Nil(t, keyAfter.Group)
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ import (
|
||||
"strings"
|
||||
|
||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||
"github.com/Wei-Shaw/sub2api/ent/apikey"
|
||||
"github.com/Wei-Shaw/sub2api/ent/group"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
@ -637,28 +636,18 @@ func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64,
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Clear group_id for api keys bound to this group.
|
||||
// 仅更新未软删除的记录,避免修改已删除数据,保证审计与历史回溯一致性。
|
||||
// 与 APIKeyRepository 的软删除语义保持一致,减少跨模块行为差异。
|
||||
if _, err := txClient.APIKey.Update().
|
||||
Where(apikey.GroupIDEQ(id), apikey.DeletedAtIsNil()).
|
||||
ClearGroupID().
|
||||
Save(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Remove the group id from user_allowed_groups join table.
|
||||
// 2. Remove the group id from user_allowed_groups join table.
|
||||
// Legacy users.allowed_groups 列已弃用,不再同步。
|
||||
if _, err := exec.ExecContext(ctx, "DELETE FROM user_allowed_groups WHERE group_id = $1", id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Delete account_groups join rows.
|
||||
// 3. Delete account_groups join rows.
|
||||
if _, err := exec.ExecContext(ctx, "DELETE FROM account_groups WHERE group_id = $1", id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 5. Soft-delete group itself.
|
||||
// 4. Soft-delete group itself.
|
||||
if _, err := txClient.Group.Delete().Where(group.IDEQ(id)).Exec(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -109,6 +109,9 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
|
||||
AbortWithError(c, 401, "USER_INACTIVE", "User account is not active")
|
||||
return
|
||||
}
|
||||
if abortIfAPIKeyGroupUnavailable(c, apiKey) {
|
||||
return
|
||||
}
|
||||
|
||||
// ── 4. SimpleMode → early return ─────────────────────────────
|
||||
|
||||
@ -251,3 +254,26 @@ func setGroupContext(c *gin.Context, group *service.Group) {
|
||||
ctx := context.WithValue(c.Request.Context(), ctxkey.Group, group)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
|
||||
func abortIfAPIKeyGroupUnavailable(c *gin.Context, apiKey *service.APIKey) bool {
|
||||
code, message, ok := validateAPIKeyGroupAvailable(apiKey)
|
||||
if ok {
|
||||
return false
|
||||
}
|
||||
AbortWithError(c, 403, code, message)
|
||||
return true
|
||||
}
|
||||
|
||||
func validateAPIKeyGroupAvailable(apiKey *service.APIKey) (string, string, bool) {
|
||||
if apiKey == nil || apiKey.GroupID == nil {
|
||||
return "", "", true
|
||||
}
|
||||
group := apiKey.Group
|
||||
if group == nil || strings.EqualFold(group.Status, "deleted") {
|
||||
return "GROUP_DELETED", "API Key 所属分组已删除", false
|
||||
}
|
||||
if !group.IsActive() {
|
||||
return "GROUP_DISABLED", "API Key 所属分组已停用", false
|
||||
}
|
||||
return "", "", true
|
||||
}
|
||||
|
||||
@ -54,6 +54,10 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
|
||||
abortWithGoogleError(c, 401, "User account is not active")
|
||||
return
|
||||
}
|
||||
if _, message, ok := validateAPIKeyGroupAvailable(apiKey); !ok {
|
||||
abortWithGoogleError(c, 403, message)
|
||||
return
|
||||
}
|
||||
|
||||
// 简易模式:跳过余额和订阅检查
|
||||
if cfg.RunMode == config.RunModeSimple {
|
||||
|
||||
@ -300,6 +300,104 @@ func TestAPIKeyAuthOverwritesInvalidContextGroup(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthRejectsUnavailableGroup(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(101)
|
||||
user := &service.User{
|
||||
ID: 7,
|
||||
Role: service.RoleUser,
|
||||
Status: service.StatusActive,
|
||||
Balance: 10,
|
||||
Concurrency: 3,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
group *service.Group
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
name: "active group passes",
|
||||
group: &service.Group{
|
||||
ID: groupID,
|
||||
Name: "active",
|
||||
Status: service.StatusActive,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Hydrated: true,
|
||||
},
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "disabled group is forbidden",
|
||||
group: &service.Group{
|
||||
ID: groupID,
|
||||
Name: "disabled",
|
||||
Status: service.StatusDisabled,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Hydrated: true,
|
||||
},
|
||||
wantStatus: http.StatusForbidden,
|
||||
wantCode: "GROUP_DISABLED",
|
||||
},
|
||||
{
|
||||
name: "deleted status group is forbidden",
|
||||
group: &service.Group{
|
||||
ID: groupID,
|
||||
Name: "deleted",
|
||||
Status: "deleted",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Hydrated: true,
|
||||
},
|
||||
wantStatus: http.StatusForbidden,
|
||||
wantCode: "GROUP_DELETED",
|
||||
},
|
||||
{
|
||||
name: "missing group edge is forbidden",
|
||||
group: nil,
|
||||
wantStatus: http.StatusForbidden,
|
||||
wantCode: "GROUP_DELETED",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
apiKey := &service.APIKey{
|
||||
ID: 100,
|
||||
UserID: user.ID,
|
||||
GroupID: &groupID,
|
||||
Key: "test-key",
|
||||
Status: service.StatusActive,
|
||||
User: user,
|
||||
Group: tt.group,
|
||||
}
|
||||
apiKeyRepo := &stubApiKeyRepo{
|
||||
getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
if key != apiKey.Key {
|
||||
return nil, service.ErrAPIKeyNotFound
|
||||
}
|
||||
clone := *apiKey
|
||||
return &clone, nil
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{RunMode: config.RunModeStandard}
|
||||
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
|
||||
router := newAuthTestRouter(apiKeyService, nil, cfg)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/t", nil)
|
||||
req.Header.Set("x-api-key", apiKey.Key)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, tt.wantStatus, w.Code)
|
||||
if tt.wantCode != "" {
|
||||
require.Contains(t, w.Body.String(), tt.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyAuthIPRestrictionDoesNotTrustSpoofedForwardHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@ -244,6 +244,21 @@ func (s *groupRepoStub) UpdateSortOrders(ctx context.Context, updates []GroupSor
|
||||
return nil
|
||||
}
|
||||
|
||||
type deleteGroupAPIKeyRepoStub struct {
|
||||
apiKeyRepoStubForGroupUpdate
|
||||
keys []string
|
||||
listErr error
|
||||
listGroupIDs []int64
|
||||
}
|
||||
|
||||
func (s *deleteGroupAPIKeyRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) {
|
||||
s.listGroupIDs = append(s.listGroupIDs, groupID)
|
||||
if s.listErr != nil {
|
||||
return nil, s.listErr
|
||||
}
|
||||
return s.keys, nil
|
||||
}
|
||||
|
||||
type proxyRepoStub struct {
|
||||
deleteErr error
|
||||
countErr error
|
||||
@ -500,6 +515,23 @@ func TestAdminService_DeleteGroup_Success_WithCacheInvalidation(t *testing.T) {
|
||||
}, calls)
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteGroup_InvalidatesAuthCacheForBoundKeys(t *testing.T) {
|
||||
repo := &groupRepoStub{}
|
||||
apiKeyRepo := &deleteGroupAPIKeyRepoStub{keys: []string{"k1", "k2"}}
|
||||
invalidator := &authCacheInvalidatorStub{}
|
||||
svc := &adminServiceImpl{
|
||||
groupRepo: repo,
|
||||
apiKeyRepo: apiKeyRepo,
|
||||
authCacheInvalidator: invalidator,
|
||||
}
|
||||
|
||||
err := svc.DeleteGroup(context.Background(), 5)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []int64{5}, repo.deleteCalls)
|
||||
require.Equal(t, []int64{5}, apiKeyRepo.listGroupIDs)
|
||||
require.Equal(t, []string{"k1", "k2"}, invalidator.keys)
|
||||
}
|
||||
|
||||
func TestAdminService_DeleteGroup_NotFound(t *testing.T) {
|
||||
repo := &groupRepoStub{deleteErr: ErrGroupNotFound}
|
||||
svc := &adminServiceImpl{groupRepo: repo}
|
||||
|
||||
@ -14,7 +14,7 @@ import (
|
||||
"github.com/dgraph-io/ristretto"
|
||||
)
|
||||
|
||||
const apiKeyAuthSnapshotVersion = 9 // v9: added API Key name for audit logs
|
||||
const apiKeyAuthSnapshotVersion = 10 // v10: reload snapshots for group availability checks
|
||||
|
||||
type apiKeyAuthCacheConfig struct {
|
||||
l1Size int
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user