diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go index 7b9a1ee0..d33ccbf5 100644 --- a/backend/internal/server/middleware/api_key_auth.go +++ b/backend/internal/server/middleware/api_key_auth.go @@ -263,6 +263,7 @@ func abortIfAPIKeyGroupUnavailable(c *gin.Context, apiKey *service.APIKey) bool if ok { return false } + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable) AbortWithError(c, 403, code, message) return true } diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go index 57e69f10..76a24192 100644 --- a/backend/internal/server/middleware/api_key_auth_test.go +++ b/backend/internal/server/middleware/api_key_auth_test.go @@ -317,6 +317,7 @@ func TestAPIKeyAuthRejectsUnavailableGroup(t *testing.T) { group *service.Group wantStatus int wantCode string + wantMarked bool }{ { name: "active group passes", @@ -340,6 +341,7 @@ func TestAPIKeyAuthRejectsUnavailableGroup(t *testing.T) { }, wantStatus: http.StatusForbidden, wantCode: "GROUP_DISABLED", + wantMarked: true, }, { name: "deleted status group is forbidden", @@ -352,12 +354,14 @@ func TestAPIKeyAuthRejectsUnavailableGroup(t *testing.T) { }, wantStatus: http.StatusForbidden, wantCode: "GROUP_DELETED", + wantMarked: true, }, { name: "missing group edge is forbidden", group: nil, wantStatus: http.StatusForbidden, wantCode: "GROUP_DELETED", + wantMarked: true, }, } @@ -383,7 +387,20 @@ func TestAPIKeyAuthRejectsUnavailableGroup(t *testing.T) { } cfg := &config.Config{RunMode: config.RunModeStandard} apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg) - router := newAuthTestRouter(apiKeyService, nil, cfg) + router := gin.New() + var markedBusinessLimited bool + var businessLimitedReason string + router.Use(func(c *gin.Context) { + c.Next() + markedBusinessLimited = service.HasOpsClientBusinessLimited(c) + if v, ok := c.Get(service.OpsClientBusinessLimitedReasonKey); ok { + businessLimitedReason, _ = v.(string) + } + }) + router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg))) + router.GET("/t", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/t", nil) @@ -394,10 +411,57 @@ func TestAPIKeyAuthRejectsUnavailableGroup(t *testing.T) { if tt.wantCode != "" { require.Contains(t, w.Body.String(), tt.wantCode) } + require.Equal(t, tt.wantMarked, markedBusinessLimited) + if tt.wantMarked { + require.Equal(t, service.OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable, businessLimitedReason) + } }) } } +func TestRequireGroupAssignmentMarksUngroupedKeyBusinessLimited(t *testing.T) { + gin.SetMode(gin.TestMode) + + settingService := service.NewSettingService(fakeSettingRepo{ + values: map[string]string{ + service.SettingKeyAllowUngroupedKeyScheduling: "false", + }, + }, &config.Config{}) + apiKey := &service.APIKey{ + ID: 100, + Key: "ungrouped-key", + Status: service.StatusActive, + } + + router := gin.New() + var markedBusinessLimited bool + var businessLimitedReason string + router.Use(func(c *gin.Context) { + c.Next() + markedBusinessLimited = service.HasOpsClientBusinessLimited(c) + if v, ok := c.Get(service.OpsClientBusinessLimitedReasonKey); ok { + businessLimitedReason, _ = v.(string) + } + }) + router.Use(func(c *gin.Context) { + c.Set(string(ContextKeyAPIKey), apiKey) + c.Next() + }) + router.Use(RequireGroupAssignment(settingService, AnthropicErrorWriter)) + router.GET("/t", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/t", nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusForbidden, w.Code) + require.Contains(t, w.Body.String(), "not assigned to any group") + require.True(t, markedBusinessLimited) + require.Equal(t, service.OpsClientBusinessLimitedReasonAPIKeyGroupUnassigned, businessLimitedReason) +} + func TestAPIKeyAuthIPRestrictionDoesNotTrustForwardedClientIPByDefault(t *testing.T) { gin.SetMode(gin.TestMode) @@ -771,6 +835,41 @@ type stubUserSubscriptionRepo struct { resetMonthly func(ctx context.Context, id int64, start time.Time) error } +type fakeSettingRepo struct { + values map[string]string +} + +func (r fakeSettingRepo) Get(ctx context.Context, key string) (*service.Setting, error) { + return nil, errors.New("not implemented") +} + +func (r fakeSettingRepo) GetValue(ctx context.Context, key string) (string, error) { + if v, ok := r.values[key]; ok { + return v, nil + } + return "", service.ErrSettingNotFound +} + +func (r fakeSettingRepo) Set(ctx context.Context, key, value string) error { + return errors.New("not implemented") +} + +func (r fakeSettingRepo) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + return nil, errors.New("not implemented") +} + +func (r fakeSettingRepo) SetMultiple(ctx context.Context, settings map[string]string) error { + return errors.New("not implemented") +} + +func (r fakeSettingRepo) GetAll(ctx context.Context) (map[string]string, error) { + return nil, errors.New("not implemented") +} + +func (r fakeSettingRepo) Delete(ctx context.Context, key string) error { + return errors.New("not implemented") +} + func (r *stubUserSubscriptionRepo) Create(ctx context.Context, sub *service.UserSubscription) error { return errors.New("not implemented") } diff --git a/backend/internal/server/middleware/middleware.go b/backend/internal/server/middleware/middleware.go index 27985cf8..d42eacec 100644 --- a/backend/internal/server/middleware/middleware.go +++ b/backend/internal/server/middleware/middleware.go @@ -115,6 +115,7 @@ func RequireGroupAssignment(settingService *service.SettingService, writeError G c.Next() return } + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonAPIKeyGroupUnassigned) writeError(c, http.StatusForbidden, "API Key is not assigned to any group and cannot be used. Please contact the administrator to assign it to a group.") c.Abort() }