From 00720cac880a2e306aaaeb4f194b7e0792c98d00 Mon Sep 17 00:00:00 2001 From: Flechas Bentley Date: Thu, 25 Jun 2026 11:46:18 -0500 Subject: [PATCH 1/7] chore: Migrate Toggle Flag - BED-8586 --- cmd/api/src/api/registration/v2.go | 3 - cmd/api/src/api/v2/flag.go | 83 ----- cmd/api/src/api/v2/flag_test.go | 98 ------ .../internal/handlers/mocks/analysis.go | 15 - .../internal/services/mocks/database.go | 15 - .../analysis/mocks/analysisrequestadapter.go | 15 - .../{register.go => featureflags.go} | 29 +- server/featureflags/featureflags_e2e_test.go | 282 +++++++++++++++++ server/featureflags/featureflags_test.go | 71 +++++ server/featureflags/internal/appdb/appdb.go | 184 ++++++++++- .../internal/appdb/appdb_integration_test.go | 165 ++++++++++ .../featureflags/internal/appdb/appdb_test.go | 293 +++++++++++++++++- .../internal/handlers/handlers.go | 126 ++++++++ .../internal/handlers/handlers_test.go | 282 +++++++++++++++++ .../internal/handlers/mocks/featureflag.go | 239 ++++++++++++++ .../featureflags/internal/handlers/views.go | 48 +++ server/featureflags/internal/routes/routes.go | 14 + .../internal/routes/routes_test.go | 95 ++++++ .../internal/services/mocks/database.go | 200 +++++++++++- .../internal/services/services.go | 44 ++- .../internal/services/services_test.go | 139 +++++++++ .../mocks/featureflagrequestadapter.go | 151 +++++++++ server/modules/modules.go | 2 + server/modules/modules_test.go | 35 ++- 24 files changed, 2350 insertions(+), 278 deletions(-) delete mode 100644 cmd/api/src/api/v2/flag.go delete mode 100644 cmd/api/src/api/v2/flag_test.go rename server/featureflags/{register.go => featureflags.go} (63%) create mode 100644 server/featureflags/featureflags_e2e_test.go create mode 100644 server/featureflags/featureflags_test.go create mode 100644 server/featureflags/internal/handlers/handlers.go create mode 100644 server/featureflags/internal/handlers/handlers_test.go create mode 100644 server/featureflags/internal/handlers/mocks/featureflag.go create mode 100644 server/featureflags/internal/handlers/views.go create mode 100644 server/featureflags/internal/routes/routes.go create mode 100644 server/featureflags/internal/routes/routes_test.go create mode 100644 server/featureflags/mocks/featureflagrequestadapter.go diff --git a/cmd/api/src/api/registration/v2.go b/cmd/api/src/api/registration/v2.go index 5202367ba75f..e56e6b9dcce7 100644 --- a/cmd/api/src/api/registration/v2.go +++ b/cmd/api/src/api/registration/v2.go @@ -155,9 +155,6 @@ func NewV2API(resources v2.Resources, routerInst *router.Router) { routerInst.GET("/api/v2/config", resources.GetApplicationConfigurations).RequirePermissions(permissions.AppReadApplicationConfiguration), routerInst.PUT("/api/v2/config", resources.SetApplicationConfiguration).RequirePermissions(permissions.AppWriteApplicationConfiguration), - routerInst.GET("/api/v2/features", resources.GetFlags).RequirePermissions(permissions.AppReadApplicationConfiguration), - routerInst.PUT("/api/v2/features/{feature_id}/toggle", resources.ToggleFlag).RequirePermissions(permissions.AppWriteApplicationConfiguration), - routerInst.POST("/api/v2/clear-database", resources.HandleDatabaseWipe).RequirePermissions(permissions.WipeDB), // Asset Groups API diff --git a/cmd/api/src/api/v2/flag.go b/cmd/api/src/api/v2/flag.go deleted file mode 100644 index 0f267b1e0cbb..000000000000 --- a/cmd/api/src/api/v2/flag.go +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright 2023 Specter Ops, Inc. -// -// Licensed under the Apache License, Version 2.0 -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package v2 - -import ( - "fmt" - "log/slog" - "net/http" - "strconv" - - "github.com/gorilla/mux" - "github.com/specterops/bloodhound/cmd/api/src/api" - "github.com/specterops/bloodhound/cmd/api/src/auth" - "github.com/specterops/bloodhound/cmd/api/src/bhctx" - "github.com/specterops/bloodhound/cmd/api/src/model/appcfg" -) - -type ListFlagsResponse struct { - Data []appcfg.FeatureFlag `json:"data"` -} - -func (s Resources) GetFlags(response http.ResponseWriter, request *http.Request) { - if flags, err := s.DB.GetAllFlags(request.Context()); err != nil { - api.HandleDatabaseError(request, response, err) - } else { - api.WriteBasicResponse(request.Context(), flags, http.StatusOK, response) - } -} - -type ToggleFlagResponse struct { - Enabled bool `json:"enabled"` -} - -func (s Resources) ToggleFlag(response http.ResponseWriter, request *http.Request) { - rawFeatureID := mux.Vars(request)[api.URIPathVariableFeatureID] - - if featureID, err := strconv.ParseInt(rawFeatureID, 10, 32); err != nil { - api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsIDMalformed, request), response) - } else if featureFlag, err := s.DB.GetFlag(request.Context(), int32(featureID)); err != nil { - api.HandleDatabaseError(request, response, err) - } else if !featureFlag.UserUpdatable { - api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, fmt.Sprintf("Feature flag %s(%d) is not user updatable.", featureFlag.Key, featureID), request), response) - } else { - featureFlag.Enabled = !featureFlag.Enabled - - if err := s.DB.SetFlag(request.Context(), featureFlag); err != nil { - api.HandleDatabaseError(request, response, err) - } else { - // TODO: Cleanup #ADCSFeatureFlag after full launch. - if featureFlag.Key == appcfg.FeatureAdcs && !featureFlag.Enabled { - var userId string - if user, isUser := auth.GetUserFromAuthCtx(bhctx.FromRequest(request).AuthCtx); !isUser { - slog.WarnContext(request.Context(), "Encountered request analysis for unknown user, this shouldn't happen") - userId = "unknown-user-toggle-flag" - } else { - userId = user.ID.String() - } - - if err := s.DB.RequestAnalysis(request.Context(), userId); err != nil { - api.HandleDatabaseError(request, response, err) - return - } - } - api.WriteBasicResponse(request.Context(), ToggleFlagResponse{ - Enabled: featureFlag.Enabled, - }, http.StatusOK, response) - } - } -} diff --git a/cmd/api/src/api/v2/flag_test.go b/cmd/api/src/api/v2/flag_test.go deleted file mode 100644 index 8a473e349c67..000000000000 --- a/cmd/api/src/api/v2/flag_test.go +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright 2023 Specter Ops, Inc. -// -// Licensed under the Apache License, Version 2.0 -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 - -package v2_test - -import ( - "errors" - "net/http" - "testing" - - "github.com/specterops/bloodhound/cmd/api/src/api" - v2 "github.com/specterops/bloodhound/cmd/api/src/api/v2" - "github.com/specterops/bloodhound/cmd/api/src/database/mocks" - "github.com/specterops/bloodhound/cmd/api/src/model/appcfg" - "github.com/specterops/bloodhound/cmd/api/src/utils/test" - "go.uber.org/mock/gomock" -) - -func TestResources_GetFlags(t *testing.T) { - var ( - mockCtrl = gomock.NewController(t) - mockDB = mocks.NewMockDatabase(mockCtrl) - resources = v2.Resources{DB: mockDB} - ) - - defer mockCtrl.Finish() - - mockDB.EXPECT().GetAllFlags(gomock.Any()).Return([]appcfg.FeatureFlag{}, nil) - - test.Request(t). - WithMethod(http.MethodGet). - WithURL("/api/v2/features"). - OnHandlerFunc(resources.GetFlags). - Require(). - ResponseStatusCode(http.StatusOK). - ResponseJSONBody(v2.ListFlagsResponse{ - Data: []appcfg.FeatureFlag{}, - }) - - mockDB.EXPECT().GetAllFlags(gomock.Any()).Return(nil, errors.New("db error")) - - test.Request(t). - WithMethod(http.MethodGet). - WithURL("/api/v2/features"). - OnHandlerFunc(resources.GetFlags). - Require(). - ResponseStatusCode(http.StatusInternalServerError) -} - -func TestResources_ToggleFlag(t *testing.T) { - const ( - featureID = int32(1) - featureIDStr = "1" - ) - - var ( - mockCtrl = gomock.NewController(t) - mockDB = mocks.NewMockDatabase(mockCtrl) - resources = v2.Resources{DB: mockDB} - requestSetup = test.Request(t). - WithMethod(http.MethodPut). - WithURL("/api/v2/features/%s/toggle", featureIDStr). - WithURLPathVars(map[string]string{api.URIPathVariableFeatureID: featureIDStr}). - OnHandlerFunc(resources.ToggleFlag) - ) - - defer mockCtrl.Finish() - - mockDB.EXPECT().GetFlag(gomock.Any(), featureID).Return(appcfg.FeatureFlag{ - UserUpdatable: false, - }, nil) - - requestSetup.Require().ResponseStatusCode(http.StatusForbidden) - - mockDB.EXPECT().GetFlag(gomock.Any(), featureID).Return(appcfg.FeatureFlag{ - UserUpdatable: true, - }, nil) - mockDB.EXPECT().SetFlag(gomock.Any(), gomock.Any()).Return(nil) - - requestSetup.Require(). - ResponseStatusCode(http.StatusOK). - ResponseJSONBody(v2.ToggleFlagResponse{ - Enabled: true, - }) -} diff --git a/server/analysis/internal/handlers/mocks/analysis.go b/server/analysis/internal/handlers/mocks/analysis.go index 1fb8e1a2718a..735bce272e17 100644 --- a/server/analysis/internal/handlers/mocks/analysis.go +++ b/server/analysis/internal/handlers/mocks/analysis.go @@ -1,18 +1,3 @@ -// Copyright 2026 Specter Ops, Inc. -// -// Licensed under the Apache License, Version 2.0 -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify diff --git a/server/analysis/internal/services/mocks/database.go b/server/analysis/internal/services/mocks/database.go index b52b9f8cc10b..e87b72e7dc85 100644 --- a/server/analysis/internal/services/mocks/database.go +++ b/server/analysis/internal/services/mocks/database.go @@ -1,18 +1,3 @@ -// Copyright 2026 Specter Ops, Inc. -// -// Licensed under the Apache License, Version 2.0 -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify diff --git a/server/analysis/mocks/analysisrequestadapter.go b/server/analysis/mocks/analysisrequestadapter.go index f154dbf182ba..6bdad1ceb35c 100644 --- a/server/analysis/mocks/analysisrequestadapter.go +++ b/server/analysis/mocks/analysisrequestadapter.go @@ -1,18 +1,3 @@ -// Copyright 2026 Specter Ops, Inc. -// -// Licensed under the Apache License, Version 2.0 -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify diff --git a/server/featureflags/register.go b/server/featureflags/featureflags.go similarity index 63% rename from server/featureflags/register.go rename to server/featureflags/featureflags.go index 5ab22612233a..889574bcf114 100644 --- a/server/featureflags/register.go +++ b/server/featureflags/featureflags.go @@ -22,9 +22,13 @@ package featureflags import ( "context" + "net/http" "github.com/jackc/pgx/v5/pgxpool" + "github.com/specterops/bloodhound/cmd/api/src/api/router" "github.com/specterops/bloodhound/server/featureflags/internal/appdb" + "github.com/specterops/bloodhound/server/featureflags/internal/handlers" + "github.com/specterops/bloodhound/server/featureflags/internal/routes" "github.com/specterops/bloodhound/server/featureflags/internal/services" ) @@ -33,12 +37,27 @@ const ( FeatureAlerts = services.FeatureAlerts ) -type Service interface { +type FeatureFlagRequestAdapter interface { IsEnabled(ctx context.Context, key string) (bool, error) + ToggleFlag(response http.ResponseWriter, request *http.Request) } -// Register wires the feature-flag service to its PostgreSQL store and returns -// the constructed service for use by BHE feature slices. -func Register(pool *pgxpool.Pool) Service { - return services.NewService(appdb.NewStore(pool)) +func NewFeatureFlagRequestAdapter(pool *pgxpool.Pool) FeatureFlagRequestAdapter { + var ( + store = appdb.NewStore(pool) + svc = services.NewService(store) + handlerSet = handlers.NewHandlersContainer(svc) + ) + + return handlerSet +} + +func Register(routerInst *router.Router, pool *pgxpool.Pool) { + var ( + store = appdb.NewStore(pool) + svc = services.NewService(store) + handlerSet = handlers.NewHandlersContainer(svc) + ) + + routes.Register(routerInst, handlerSet) } diff --git a/server/featureflags/featureflags_e2e_test.go b/server/featureflags/featureflags_e2e_test.go new file mode 100644 index 000000000000..2c3d813310b6 --- /dev/null +++ b/server/featureflags/featureflags_e2e_test.go @@ -0,0 +1,282 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package featureflags_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/gofrs/uuid" + "github.com/gorilla/mux" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/peterldowns/pgtestdb" + "github.com/specterops/bloodhound/cmd/api/src/api" + "github.com/specterops/bloodhound/cmd/api/src/auth" + "github.com/specterops/bloodhound/cmd/api/src/bhctx" + "github.com/specterops/bloodhound/cmd/api/src/config" + "github.com/specterops/bloodhound/cmd/api/src/database" + "github.com/specterops/bloodhound/cmd/api/src/model" + "github.com/specterops/bloodhound/cmd/api/src/test/integration/utils" + "github.com/specterops/bloodhound/server/featureflags/internal/appdb" + "github.com/specterops/bloodhound/server/featureflags/internal/handlers" + "github.com/specterops/bloodhound/server/featureflags/internal/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// injectAuthMiddleware wraps the given handler, attaching a bhctx.Context that +// identifies the supplied user as the request owner. This stands in for the +// production auth middleware so feature-flag handlers can resolve a user +// without requiring the full auth stack. +func injectAuthMiddleware(handler http.HandlerFunc, userID uuid.UUID) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + bhCtx := &bhctx.Context{ + AuthCtx: auth.Context{Owner: model.User{Unique: model.Unique{ID: userID}}}, + } + handler(w, bhctx.SetRequestContext(r, bhCtx)) + } +} + +// featureFlagsEnvelope is the JSON envelope returned by GET /api/v2/features. +type featureFlagsEnvelope struct { + Data handlers.FeatureFlagsView `json:"data"` +} + +// featureFlagEnvelope is the JSON envelope returned by PUT /api/v2/features/{feature_id}/toggle. +type featureFlagEnvelope struct { + Data handlers.FeatureFlagView `json:"data"` +} + +// setupFeatureFlagsDB creates an isolated test database with all migrations applied. +// The database is automatically closed when the test ends. +func setupFeatureFlagsDB(t *testing.T) *database.BloodhoundDB { + t.Helper() + + var ( + ctx = context.Background() + connConf = pgtestdb.Custom(t, getFeatureFlagsPostgresConfig(t), pgtestdb.NoopMigrator{}) + ) + + cfg, err := config.NewDefaultConnectionConfiguration(connConf.URL()) + require.NoError(t, err) + + gormDB, dbPool, err := database.OpenDatabase(cfg.Database) + require.NoError(t, err) + + db := database.NewBloodhoundDB(gormDB, dbPool, auth.NewIdentityResolver(), cfg) + require.NoError(t, db.Migrate(ctx)) + + t.Cleanup(func() { db.Close(ctx) }) + + return db +} + +// getFeatureFlagsPostgresConfig reads the integration test configuration from the +// environment and returns a pgtestdb.Config for the featureflags e2e tests. +func getFeatureFlagsPostgresConfig(t *testing.T) pgtestdb.Config { + t.Helper() + + cfg, err := utils.LoadIntegrationTestConfig() + require.NoError(t, err) + + environmentMap := make(map[string]string) + for entry := range strings.FieldsSeq(cfg.Database.Connection) { + if parts := strings.SplitN(entry, "=", 2); len(parts) == 2 { + environmentMap[parts[0]] = parts[1] + } + } + + if strings.HasPrefix(environmentMap["host"], "/") { + return pgtestdb.Config{ + DriverName: "pgx", + User: environmentMap["user"], + Password: environmentMap["password"], + Database: environmentMap["dbname"], + Options: fmt.Sprintf("host=%s", url.PathEscape(environmentMap["host"])), + TestRole: &pgtestdb.Role{ + Username: environmentMap["user"], + Password: environmentMap["password"], + Capabilities: "NOSUPERUSER NOCREATEROLE", + }, + } + } + + return pgtestdb.Config{ + DriverName: "pgx", + Host: environmentMap["host"], + Port: environmentMap["port"], + User: environmentMap["user"], + Password: environmentMap["password"], + Database: environmentMap["dbname"], + Options: "sslmode=disable", + ForceTerminateConnections: true, + } +} + +// newGetAllFlagsHandler wires the pgx-backed featureflags stack from a BloodhoundDB +// and returns its GET handler. +func newGetAllFlagsHandler(db *database.BloodhoundDB) http.HandlerFunc { + store := appdb.NewStore(db.Pool()) + svc := services.NewService(store) + return handlers.NewHandlersContainer(svc).GetAllFlags +} + +// newToggleFlagHandler wires the PUT /api/v2/features/{feature_id}/toggle handler backed +// by a pgx-backed featureflags stack and wrapped with auth-injecting middleware. +func newToggleFlagHandler(db *database.BloodhoundDB, userID uuid.UUID) http.HandlerFunc { + store := appdb.NewStore(db.Pool()) + svc := services.NewService(store) + return injectAuthMiddleware(handlers.NewHandlersContainer(svc).ToggleFlag, userID) +} + +// seedFeatureFlag inserts a feature_flags row directly, bypassing the Store API +// which does not expose flag creation. Returns the inserted flag's ID. +func seedFeatureFlag(t *testing.T, ctx context.Context, pool *pgxpool.Pool, key, name string, enabled, userUpdatable bool) int32 { + t.Helper() + + var id int32 + err := pool.QueryRow(ctx, + `INSERT INTO feature_flags (key, name, description, enabled, user_updatable, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + RETURNING id`, + key, name, "seeded flag", enabled, userUpdatable, + ).Scan(&id) + require.NoError(t, err) + return id +} + +func TestGetAllFlags(t *testing.T) { + var ( + db = setupFeatureFlagsDB(t) + ctx = context.Background() + muxRouter = mux.NewRouter() + server = httptest.NewServer(muxRouter) + ) + muxRouter.HandleFunc("/api/v2/features", newGetAllFlagsHandler(db)).Methods(http.MethodGet) + t.Cleanup(server.Close) + + newGetRequest := func(t *testing.T) *http.Request { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/api/v2/features", nil) + require.NoError(t, err) + return req + } + + t.Run("returns 200 OK with the seeded flags", func(t *testing.T) { + seeded := seedFeatureFlag(t, ctx, db.Pool(), "e2e_get_all_flag", "E2E Get All Flag", true, true) + + resp, err := http.DefaultClient.Do(newGetRequest(t)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + + var envelope featureFlagsEnvelope + require.NoError(t, json.NewDecoder(resp.Body).Decode(&envelope)) + + found := false + for _, flag := range envelope.Data { + if flag.ID == seeded { + found = true + assert.Equal(t, "e2e_get_all_flag", flag.Key) + assert.Equal(t, "E2E Get All Flag", flag.Name) + assert.True(t, flag.Enabled) + assert.True(t, flag.UserUpdatable) + } + } + assert.True(t, found, "seeded flag should be in the returned list") + }) +} + +func TestToggleFlag(t *testing.T) { + var ( + db = setupFeatureFlagsDB(t) + userID = uuid.Must(uuid.NewV4()) + ctx = context.Background() + ) + + newToggleRequest := func(t *testing.T, server *httptest.Server, featureID int32) *http.Request { + t.Helper() + target := fmt.Sprintf("%s/api/v2/features/%d/toggle", server.URL, featureID) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, target, nil) + require.NoError(t, err) + return req + } + + newServer := func(t *testing.T) *httptest.Server { + t.Helper() + muxRouter := mux.NewRouter() + muxRouter.HandleFunc( + "/api/v2/features/{"+api.URIPathVariableFeatureID+"}/toggle", + newToggleFlagHandler(db, userID), + ).Methods(http.MethodPut) + server := httptest.NewServer(muxRouter) + t.Cleanup(server.Close) + return server + } + + t.Run("returns 200 OK and flips the flag when it is user-updatable", func(t *testing.T) { + var ( + server = newServer(t) + featureID = seedFeatureFlag(t, ctx, db.Pool(), "e2e_toggle_flag", "E2E Toggle Flag", false, true) + ) + + resp, err := http.DefaultClient.Do(newToggleRequest(t, server, featureID)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var envelope featureFlagEnvelope + require.NoError(t, json.NewDecoder(resp.Body).Decode(&envelope)) + assert.Equal(t, featureID, envelope.Data.ID) + assert.True(t, envelope.Data.Enabled, "Enabled should have flipped from false to true") + }) + + t.Run("returns 403 Forbidden when the flag is not user-updatable", func(t *testing.T) { + var ( + server = newServer(t) + featureID = seedFeatureFlag(t, ctx, db.Pool(), "e2e_locked_flag", "E2E Locked Flag", false, false) + ) + + resp, err := http.DefaultClient.Do(newToggleRequest(t, server, featureID)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + }) + + t.Run("returns 404 Not Found when the flag does not exist", func(t *testing.T) { + server := newServer(t) + + resp, err := http.DefaultClient.Do(newToggleRequest(t, server, 999999)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + diff --git a/server/featureflags/featureflags_test.go b/server/featureflags/featureflags_test.go new file mode 100644 index 000000000000..bcb4296df10b --- /dev/null +++ b/server/featureflags/featureflags_test.go @@ -0,0 +1,71 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package featureflags_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/specterops/bloodhound/cmd/api/src/api/router" + "github.com/specterops/bloodhound/cmd/api/src/auth" + "github.com/specterops/bloodhound/cmd/api/src/config" + "github.com/specterops/bloodhound/server/featureflags" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewFeatureFlagRequestAdapter(t *testing.T) { + t.Run("returns non-nil adapter", func(t *testing.T) { + pool := new(pgxpool.Pool) + adapter := featureflags.NewFeatureFlagRequestAdapter(pool) + + require.NotNil(t, adapter) + }) +} + +func TestRegister(t *testing.T) { + t.Run("successfully registers featureflags routes", func(t *testing.T) { + var ( + cfg = config.Configuration{} + authorizer = auth.NewAuthorizer(nil) + routerInst = router.NewRouter(cfg, authorizer, "") + pool = new(pgxpool.Pool) + ) + + // Should not panic + require.NotPanics(t, func() { + featureflags.Register(&routerInst, pool) + }) + + // Verify routes are registered + var ( + muxRouter = routerInst.MuxRouter() + match mux.RouteMatch + ) + + // Test GET /api/v2/features route + getRequest := httptest.NewRequest(http.MethodGet, "/api/v2/features", nil) + assert.True(t, muxRouter.Match(getRequest, &match), "GET /api/v2/features route should be registered") + + // Test PUT /api/v2/features/{feature_id}/toggle route + putRequest := httptest.NewRequest(http.MethodPut, "/api/v2/features/1/toggle", nil) + assert.True(t, muxRouter.Match(putRequest, &match), "PUT /api/v2/features/{feature_id}/toggle route should be registered") + }) +} diff --git a/server/featureflags/internal/appdb/appdb.go b/server/featureflags/internal/appdb/appdb.go index 41697efa254f..e3d5f0d700d8 100644 --- a/server/featureflags/internal/appdb/appdb.go +++ b/server/featureflags/internal/appdb/appdb.go @@ -17,23 +17,39 @@ package appdb import ( "context" + "encoding/json" "errors" "fmt" + "time" + "github.com/gofrs/uuid" "github.com/huandu/go-sqlbuilder" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/specterops/bloodhound/cmd/api/src/auth" + "github.com/specterops/bloodhound/cmd/api/src/bhctx" "github.com/specterops/bloodhound/cmd/api/src/database/types/null" + "github.com/specterops/bloodhound/cmd/api/src/model" "github.com/specterops/bloodhound/server/featureflags/internal/services" ) const ( tableFeatureFlags = "feature_flags" + tableAuditLogs = "audit_logs" ) -// pgxQuerier is the minimal pgx surface the feature-flag store relies on. It is +// queryExecer is the minimal pgx surface the feature-flag store relies on. It is // satisfied by both *pgxpool.Pool and the test doubles used in unit tests. -type pgxQuerier interface { +type queryExecer interface { Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) +} + +// pgxQuerier extends queryExecer with the ability to begin a transaction. +// Only *pgxpool.Pool satisfies this full interface; pgx.Tx satisfies only queryExecer. +type pgxQuerier interface { + queryExecer + BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) } // featureFlagRow holds the raw scanned values for a feature_flags row. The db @@ -77,10 +93,29 @@ func NewStore(db pgxQuerier) *Store { // GetFlagByKey returns the feature flag for the supplied key, or ErrNotFound // when no matching flag exists. func (s *Store) GetFlagByKey(ctx context.Context, key string) (services.FeatureFlag, error) { + return selectFeatureFlag(ctx, s.db, func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.Equal("key", key)) + }) +} + +// GetFlagByID returns the feature flag for the supplied id, or ErrNotFound +// when no matching flag exists. +func (s *Store) GetFlagByID(ctx context.Context, id int32) (services.FeatureFlag, error) { + return selectFeatureFlag(ctx, s.db, func(sb *sqlbuilder.SelectBuilder) { + sb.Where(sb.Equal("id", id)) + }) +} + +// selectFeatureFlag builds and executes a single-row SELECT against the +// feature_flags table, applying caller-supplied WHERE conditions via +// applyConditions. It returns ErrNotFound when no row matches. +func selectFeatureFlag(ctx context.Context, querier queryExecer, applyConditions func(sb *sqlbuilder.SelectBuilder)) (services.FeatureFlag, error) { var ( - row featureFlagRow - rows pgx.Rows - err error + row featureFlagRow + rows pgx.Rows + err error + sqlQuery string + queryArgs []any ) selectBuilder := sqlbuilder.PostgreSQL.NewSelectBuilder() @@ -95,12 +130,15 @@ func (s *Store) GetFlagByKey(ctx context.Context, key string) (services.FeatureF "user_updatable", ) selectBuilder.From(tableFeatureFlags) - selectBuilder.Where(selectBuilder.Equal("key", key)) + applyConditions(selectBuilder) selectBuilder.Limit(1) - sqlQuery, args := selectBuilder.Build() + sqlQuery, queryArgs = selectBuilder.Build() - rows, err = s.db.Query(ctx, sqlQuery, args...) + rows, err = querier.Query(ctx, sqlQuery, queryArgs...) + if errors.Is(err, pgx.ErrNoRows) { + return services.FeatureFlag{}, services.ErrNotFound + } if err != nil { return services.FeatureFlag{}, err } @@ -110,7 +148,135 @@ func (s *Store) GetFlagByKey(ctx context.Context, key string) (services.FeatureF return services.FeatureFlag{}, services.ErrNotFound } if err != nil { - return services.FeatureFlag{}, fmt.Errorf("reading rows: %s", err) + return services.FeatureFlag{}, fmt.Errorf("reading rows: %w", err) } return toFeatureFlag(row), nil } + +// GetAllFlags returns every feature flag in the feature_flags table. +func (s *Store) GetAllFlags(ctx context.Context) ([]services.FeatureFlag, error) { + var ( + rows pgx.Rows + err error + sqlQuery string + queryArgs []any + ) + + selectBuilder := sqlbuilder.PostgreSQL.NewSelectBuilder() + selectBuilder.Select( + "id", + "created_at", + "updated_at", + "key", + "name", + "description", + "enabled", + "user_updatable", + ) + selectBuilder.From(tableFeatureFlags) + + sqlQuery, queryArgs = selectBuilder.Build() + + rows, err = s.db.Query(ctx, sqlQuery, queryArgs...) + if err != nil { + return nil, err + } + + dbRows, err := pgx.CollectRows(rows, pgx.RowToStructByName[featureFlagRow]) + if err != nil { + return nil, fmt.Errorf("reading rows: %w", err) + } + + flags := make([]services.FeatureFlag, 0, len(dbRows)) + for _, row := range dbRows { + flags = append(flags, toFeatureFlag(row)) + } + return flags, nil +} + +// TODO: This will be used in middleware and will need to be removed when implemented +func insertAuditLog(ctx context.Context, querier queryExecer, flag services.FeatureFlag) error { + var ( + commitID, err = uuid.NewV4() + bheCtx = bhctx.Get(ctx) + user, isUser = auth.GetUserFromAuthCtx(bheCtx.AuthCtx) + ) + if err != nil { + return fmt.Errorf("generating commit id: %w", err) + } + if !isUser { + return fmt.Errorf("no authenticated user on context") + } + + fields, err := json.Marshal(flag.AuditData()) + if err != nil { + return fmt.Errorf("marshalling audit fields: %w", err) + } + + insertBuilder := sqlbuilder.PostgreSQL.NewInsertBuilder() + insertBuilder.InsertInto(tableAuditLogs) + insertBuilder.Cols( + "created_at", "actor_id", "actor_name", "actor_email", + "action", "fields", "request_id", "source_ip_address", + "status", "commit_id", + ) + insertBuilder.Values( + time.Now().UTC(), + user.ID.String(), + user.PrincipalName, + user.EmailAddress.ValueOrZero(), + string(model.AuditLogActionToggleEarlyAccessFeatureFlag), + string(fields), + bheCtx.RequestID, + bheCtx.RequestIP, + string(model.AuditLogStatusSuccess), + commitID.String(), + ) + + sqlQuery, args := insertBuilder.Build() + _, err = querier.Exec(ctx, sqlQuery, args...) + return err +} + +// SetFlag updates a feature flag's enablement. When the flag is user-updatable, +// an audit log entry is written in the same transaction as the update. +func (s *Store) SetFlag(ctx context.Context, flag services.FeatureFlag) error { + var ( + updateBuilder = sqlbuilder.PostgreSQL.NewUpdateBuilder() + tx pgx.Tx + err error + ) + + tx, err = s.db.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return fmt.Errorf("beginning transaction: %w", err) + } + defer func() { + // Rollback is a no-op once the transaction has been committed. + _ = tx.Rollback(ctx) + }() + + updateBuilder.Update(tableFeatureFlags) + updateBuilder.Set( + updateBuilder.Assign("enabled", flag.Enabled), + updateBuilder.Assign("updated_at", time.Now().UTC()), + ) + updateBuilder.Where(updateBuilder.Equal("id", flag.ID)) + + sqlQuery, args := updateBuilder.Build() + _, err = tx.Exec(ctx, sqlQuery, args...) + if errors.Is(err, pgx.ErrNoRows) { + return services.ErrNotFound + } + if err != nil { + return err + } + + if flag.UserUpdatable { + if err = insertAuditLog(ctx, tx, flag); err != nil { + return err + } + } + + return tx.Commit(ctx) +} diff --git a/server/featureflags/internal/appdb/appdb_integration_test.go b/server/featureflags/internal/appdb/appdb_integration_test.go index d8a55643398d..d613053f14bc 100644 --- a/server/featureflags/internal/appdb/appdb_integration_test.go +++ b/server/featureflags/internal/appdb/appdb_integration_test.go @@ -13,6 +13,7 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 + //go:build integration package appdb_test @@ -24,11 +25,14 @@ import ( "strings" "testing" + "github.com/gofrs/uuid" "github.com/jackc/pgx/v5/pgxpool" "github.com/peterldowns/pgtestdb" "github.com/specterops/bloodhound/cmd/api/src/auth" + "github.com/specterops/bloodhound/cmd/api/src/bhctx" "github.com/specterops/bloodhound/cmd/api/src/config" "github.com/specterops/bloodhound/cmd/api/src/database" + "github.com/specterops/bloodhound/cmd/api/src/model" "github.com/specterops/bloodhound/cmd/api/src/test/integration/utils" "github.com/specterops/bloodhound/server/featureflags/internal/appdb" "github.com/specterops/bloodhound/server/featureflags/internal/services" @@ -128,3 +132,164 @@ func TestStore_GetFlagByKey_Integration_NotFound(t *testing.T) { _, err := store.GetFlagByKey(ctx, "does_not_exist_flag") assert.ErrorIs(t, err, services.ErrNotFound) } + +// seedFlag inserts a feature_flags row using the table sequence to allocate an id and +// returns the assigned id. The Store API does not expose flag creation. +func seedFlag(t *testing.T, ctx context.Context, pool *pgxpool.Pool, key, name string, enabled, userUpdatable bool) int32 { + t.Helper() + + var id int32 + err := pool.QueryRow(ctx, + `INSERT INTO feature_flags (id, key, name, description, enabled, user_updatable, created_at, updated_at) + VALUES (nextval('feature_flags_id_seq'), $1, $2, $3, $4, $5, now(), now()) + RETURNING id`, + key, name, "seeded by integration test", enabled, userUpdatable, + ).Scan(&id) + require.NoError(t, err) + return id +} + +// authedContext attaches a bhctx.Context carrying the supplied user as the +// auth owner. SetFlag's audit-log path requires this when UserUpdatable is true. +func authedContext(t *testing.T) context.Context { + t.Helper() + + userID, err := uuid.NewV4() + require.NoError(t, err) + + return bhctx.Set(context.Background(), &bhctx.Context{ + RequestID: "integration-request", + RequestIP: "127.0.0.1", + AuthCtx: auth.Context{ + Owner: model.User{ + Unique: model.Unique{ID: userID}, + PrincipalName: "integration-user", + }, + }, + }) +} + +func TestStore_GetFlagByID_Integration(t *testing.T) { + t.Run("returns the flag for the supplied id", func(t *testing.T) { + var ( + ctx = context.Background() + store, pool = setupStore(t) + id = seedFlag(t, ctx, pool, "by_id_flag", "ID Flag", true, false) + ) + + flag, err := store.GetFlagByID(ctx, id) + require.NoError(t, err) + assert.Equal(t, id, flag.ID) + assert.Equal(t, "by_id_flag", flag.Key) + assert.True(t, flag.Enabled) + }) + + t.Run("returns ErrNotFound for an unknown id", func(t *testing.T) { + var ( + ctx = context.Background() + store, _ = setupStore(t) + ) + + _, err := store.GetFlagByID(ctx, 999999) + assert.ErrorIs(t, err, services.ErrNotFound) + }) +} + +func TestStore_GetAllFlags_Integration(t *testing.T) { + t.Run("includes seeded and migration-provided flags", func(t *testing.T) { + var ( + ctx = context.Background() + store, pool = setupStore(t) + seededID = seedFlag(t, ctx, pool, "get_all_flag", "Get All Flag", false, true) + ) + + flags, err := store.GetAllFlags(ctx) + require.NoError(t, err) + require.NotEmpty(t, flags, "migrations should populate baseline flags") + + found := false + for _, flag := range flags { + if flag.ID == seededID { + found = true + assert.Equal(t, "get_all_flag", flag.Key) + assert.False(t, flag.Enabled) + assert.True(t, flag.UserUpdatable) + } + } + assert.True(t, found, "seeded flag should appear in the result set") + }) +} + +func TestStore_SetFlag_Integration(t *testing.T) { + t.Run("flips a non-user-updatable flag without writing an audit log", func(t *testing.T) { + var ( + ctx = context.Background() + store, pool = setupStore(t) + id = seedFlag(t, ctx, pool, "set_flag_locked", "Locked", false, false) + ) + + updated, err := store.GetFlagByID(ctx, id) + require.NoError(t, err) + updated.Enabled = true + + require.NoError(t, store.SetFlag(ctx, updated)) + + got, err := store.GetFlagByID(ctx, id) + require.NoError(t, err) + assert.True(t, got.Enabled) + + var auditCount int + require.NoError(t, pool.QueryRow(ctx, + "SELECT COUNT(*) FROM audit_logs WHERE action = $1", + string(model.AuditLogActionToggleEarlyAccessFeatureFlag), + ).Scan(&auditCount)) + assert.Zero(t, auditCount, "no audit log should be written for non-user-updatable flags") + }) + + t.Run("flips a user-updatable flag and writes an audit log", func(t *testing.T) { + var ( + ctx = authedContext(t) + store, pool = setupStore(t) + id = seedFlag(t, ctx, pool, "set_flag_unlocked", "Unlocked", false, true) + ) + + updated, err := store.GetFlagByID(ctx, id) + require.NoError(t, err) + updated.Enabled = true + + require.NoError(t, store.SetFlag(ctx, updated)) + + got, err := store.GetFlagByID(ctx, id) + require.NoError(t, err) + assert.True(t, got.Enabled) + + var auditCount int + require.NoError(t, pool.QueryRow(ctx, + "SELECT COUNT(*) FROM audit_logs WHERE action = $1 AND actor_name = $2", + string(model.AuditLogActionToggleEarlyAccessFeatureFlag), + "integration-user", + ).Scan(&auditCount)) + assert.Equal(t, 1, auditCount, "exactly one audit log should be written") + }) + + t.Run("returns an error when no authenticated user is on the context for a user-updatable flag", func(t *testing.T) { + var ( + ctx = context.Background() + store, pool = setupStore(t) + id = seedFlag(t, ctx, pool, "set_flag_anon", "Anon", false, true) + ) + + updated, err := store.GetFlagByID(ctx, id) + require.NoError(t, err) + updated.Enabled = true + + err = store.SetFlag(ctx, updated) + require.Error(t, err) + assert.Contains(t, err.Error(), "no authenticated user on context") + + // Transaction should have rolled back, leaving the flag disabled. + got, err := store.GetFlagByID(ctx, id) + require.NoError(t, err) + assert.False(t, got.Enabled, "rolled-back update must not be visible") + }) +} diff --git a/server/featureflags/internal/appdb/appdb_test.go b/server/featureflags/internal/appdb/appdb_test.go index e2c0607b08a0..d9bb4f3418d8 100644 --- a/server/featureflags/internal/appdb/appdb_test.go +++ b/server/featureflags/internal/appdb/appdb_test.go @@ -4,7 +4,7 @@ // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// http://www.apache.org/licenses/LICENSE-2.0 +// http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, @@ -13,6 +13,7 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 + package appdb_test import ( @@ -20,17 +21,32 @@ import ( "errors" "testing" + "github.com/gofrs/uuid" "github.com/pashagolub/pgxmock/v4" + "github.com/specterops/bloodhound/cmd/api/src/auth" + "github.com/specterops/bloodhound/cmd/api/src/bhctx" + "github.com/specterops/bloodhound/cmd/api/src/model" "github.com/specterops/bloodhound/server/featureflags/internal/appdb" "github.com/specterops/bloodhound/server/featureflags/internal/services" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// expectedSelectSQL is compared via pgxmock.QueryMatcherEqual, which -// whitespace-normalises both sides; column order, table name, the WHERE -// predicate and the parameter shape are load-bearing. -const expectedSelectSQL = `SELECT id, created_at, updated_at, key, name, description, enabled, user_updatable FROM feature_flags WHERE key = $1 LIMIT $2` +// Literal SQL strings expected by the Store. These are compared via +// pgxmock.QueryMatcherEqual, which whitespace-normalises both sides, so +// column order, table name, WHERE predicate and parameter shape are +// load-bearing. +const ( + expectedSelectByKeySQL = `SELECT id, created_at, updated_at, key, name, description, enabled, user_updatable FROM feature_flags WHERE key = $1 LIMIT $2` + + expectedSelectByIDSQL = `SELECT id, created_at, updated_at, key, name, description, enabled, user_updatable FROM feature_flags WHERE id = $1 LIMIT $2` + + expectedSelectAllSQL = `SELECT id, created_at, updated_at, key, name, description, enabled, user_updatable FROM feature_flags` + + expectedUpdateSQL = `UPDATE feature_flags SET enabled = $1, updated_at = $2 WHERE id = $3` + + expectedAuditInsertSQL = `INSERT INTO audit_logs (created_at, actor_id, actor_name, actor_email, action, fields, request_id, source_ip_address, status, commit_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)` +) func newTestStore(t *testing.T) (*appdb.Store, pgxmock.PgxPoolIface) { t.Helper() @@ -44,6 +60,22 @@ func flagColumns() []string { return []string{"id", "created_at", "updated_at", "key", "name", "description", "enabled", "user_updatable"} } +// authenticatedContext attaches a bhctx.Context carrying the supplied user as +// the auth owner, mirroring what the auth middleware does on real requests. +// SetFlag's audit-log path reads the actor from this context. +func authenticatedContext(userID uuid.UUID) context.Context { + return bhctx.Set(context.Background(), &bhctx.Context{ + RequestID: "test-request", + RequestIP: "127.0.0.1", + AuthCtx: auth.Context{ + Owner: model.User{ + Unique: model.Unique{ID: userID}, + PrincipalName: "test-user", + }, + }, + }) +} + func TestStore_GetFlagByKey(t *testing.T) { var ( ctx = context.Background() @@ -59,7 +91,7 @@ func TestStore_GetFlagByKey(t *testing.T) { { name: "returns the feature flag on success", expectations: func(pool pgxmock.PgxPoolIface) { - pool.ExpectQuery(expectedSelectSQL).WithArgs(services.FeatureOpenHoundSupport, 1).WillReturnRows( + pool.ExpectQuery(expectedSelectByKeySQL).WithArgs(services.FeatureOpenHoundSupport, 1).WillReturnRows( pool.NewRows(flagColumns()).AddRow( int32(7), nil, nil, services.FeatureOpenHoundSupport, "OpenHound Support", "desc", true, false, ), @@ -70,7 +102,7 @@ func TestStore_GetFlagByKey(t *testing.T) { { name: "maps zero rows to ErrNotFound", expectations: func(pool pgxmock.PgxPoolIface) { - pool.ExpectQuery(expectedSelectSQL).WithArgs(services.FeatureOpenHoundSupport, 1).WillReturnRows( + pool.ExpectQuery(expectedSelectByKeySQL).WithArgs(services.FeatureOpenHoundSupport, 1).WillReturnRows( pool.NewRows(flagColumns()), ) }, @@ -79,7 +111,7 @@ func TestStore_GetFlagByKey(t *testing.T) { { name: "propagates other database errors", expectations: func(pool pgxmock.PgxPoolIface) { - pool.ExpectQuery(expectedSelectSQL).WithArgs(services.FeatureOpenHoundSupport, 1).WillReturnError(dbErr) + pool.ExpectQuery(expectedSelectByKeySQL).WithArgs(services.FeatureOpenHoundSupport, 1).WillReturnError(dbErr) }, wantErr: dbErr, }, @@ -101,3 +133,248 @@ func TestStore_GetFlagByKey(t *testing.T) { }) } } + +func TestStore_GetFlagByID(t *testing.T) { + var ( + ctx = context.Background() + dbErr = errors.New("connection refused") + ) + + tests := []struct { + name string + expectations func(pool pgxmock.PgxPoolIface) + wantFlag services.FeatureFlag + wantErr error + }{ + { + name: "returns the feature flag on success", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedSelectByIDSQL).WithArgs(int32(11), 1).WillReturnRows( + pool.NewRows(flagColumns()).AddRow( + int32(11), nil, nil, services.FeatureAlerts, "Alerts", "desc", false, true, + ), + ) + }, + wantFlag: services.FeatureFlag{ID: 11, Key: services.FeatureAlerts, Name: "Alerts", Description: "desc", Enabled: false, UserUpdatable: true}, + }, + { + name: "maps zero rows to ErrNotFound", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedSelectByIDSQL).WithArgs(int32(11), 1).WillReturnRows( + pool.NewRows(flagColumns()), + ) + }, + wantErr: services.ErrNotFound, + }, + { + name: "propagates other database errors", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedSelectByIDSQL).WithArgs(int32(11), 1).WillReturnError(dbErr) + }, + wantErr: dbErr, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, pool := newTestStore(t) + tt.expectations(pool) + + flag, err := store.GetFlagByID(ctx, 11) + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantFlag, flag) + } + require.NoError(t, pool.ExpectationsWereMet()) + }) + } +} + +func TestStore_GetAllFlags(t *testing.T) { + var ( + ctx = context.Background() + dbErr = errors.New("connection refused") + ) + + tests := []struct { + name string + expectations func(pool pgxmock.PgxPoolIface) + wantFlags []services.FeatureFlag + wantErr error + }{ + { + name: "returns every flag from the result set", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedSelectAllSQL).WillReturnRows( + pool.NewRows(flagColumns()). + AddRow(int32(1), nil, nil, services.FeatureOpenHoundSupport, "OpenHound", "", true, false). + AddRow(int32(2), nil, nil, services.FeatureAlerts, "Alerts", "", false, true), + ) + }, + wantFlags: []services.FeatureFlag{ + {ID: 1, Key: services.FeatureOpenHoundSupport, Name: "OpenHound", Enabled: true, UserUpdatable: false}, + {ID: 2, Key: services.FeatureAlerts, Name: "Alerts", Enabled: false, UserUpdatable: true}, + }, + }, + { + name: "returns an empty slice when no flags are configured", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedSelectAllSQL).WillReturnRows(pool.NewRows(flagColumns())) + }, + wantFlags: []services.FeatureFlag{}, + }, + { + name: "propagates database errors", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedSelectAllSQL).WillReturnError(dbErr) + }, + wantErr: dbErr, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, pool := newTestStore(t) + tt.expectations(pool) + + flags, err := store.GetAllFlags(ctx) + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantFlags, flags) + } + require.NoError(t, pool.ExpectationsWereMet()) + }) + } +} + +func TestStore_SetFlag(t *testing.T) { + var ( + userID = uuid.Must(uuid.NewV4()) + authCtx = authenticatedContext(userID) + flag = services.FeatureFlag{ID: 42, Key: services.FeatureAlerts, Name: "Alerts", Enabled: true, UserUpdatable: false} + userFlag = services.FeatureFlag{ID: 42, Key: services.FeatureAlerts, Name: "Alerts", Enabled: true, UserUpdatable: true} + dbErr = errors.New("connection refused") + ) + + tests := []struct { + name string + ctx context.Context + flag services.FeatureFlag + expectations func(pool pgxmock.PgxPoolIface) + wantErr error + }{ + { + name: "commits the update without an audit entry when the flag is not user-updatable", + ctx: context.Background(), + flag: flag, + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectBegin() + pool.ExpectExec(expectedUpdateSQL). + WithArgs(true, pgxmock.AnyArg(), int32(42)). + WillReturnResult(pgxmock.NewResult("UPDATE", 1)) + pool.ExpectCommit() + }, + }, + { + name: "commits both the update and an audit entry when the flag is user-updatable", + ctx: authCtx, + flag: userFlag, + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectBegin() + pool.ExpectExec(expectedUpdateSQL). + WithArgs(true, pgxmock.AnyArg(), int32(42)). + WillReturnResult(pgxmock.NewResult("UPDATE", 1)) + pool.ExpectExec(expectedAuditInsertSQL). + WithArgs( + pgxmock.AnyArg(), // created_at + userID.String(), // actor_id + "test-user", // actor_name + "", // actor_email + string(model.AuditLogActionToggleEarlyAccessFeatureFlag), // action + pgxmock.AnyArg(), // fields (json) + "test-request", // request_id + "127.0.0.1", // source_ip_address + string(model.AuditLogStatusSuccess), // status + pgxmock.AnyArg(), // commit_id + ). + WillReturnResult(pgxmock.NewResult("INSERT", 1)) + pool.ExpectCommit() + }, + }, + { + name: "rolls back and returns the error when BeginTx fails", + ctx: context.Background(), + flag: flag, + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectBegin().WillReturnError(dbErr) + }, + wantErr: dbErr, + }, + { + name: "rolls back when the UPDATE fails", + ctx: context.Background(), + flag: flag, + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectBegin() + pool.ExpectExec(expectedUpdateSQL). + WithArgs(true, pgxmock.AnyArg(), int32(42)). + WillReturnError(dbErr) + pool.ExpectRollback() + }, + wantErr: dbErr, + }, + { + name: "rolls back when the audit insert fails for a user-updatable flag", + ctx: authCtx, + flag: userFlag, + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectBegin() + pool.ExpectExec(expectedUpdateSQL). + WithArgs(true, pgxmock.AnyArg(), int32(42)). + WillReturnResult(pgxmock.NewResult("UPDATE", 1)) + pool.ExpectExec(expectedAuditInsertSQL). + WithArgs( + pgxmock.AnyArg(), pgxmock.AnyArg(), pgxmock.AnyArg(), pgxmock.AnyArg(), pgxmock.AnyArg(), + pgxmock.AnyArg(), pgxmock.AnyArg(), pgxmock.AnyArg(), pgxmock.AnyArg(), pgxmock.AnyArg(), + ). + WillReturnError(dbErr) + pool.ExpectRollback() + }, + wantErr: dbErr, + }, + { + name: "returns an error when no authenticated user is on the context", + ctx: context.Background(), + flag: userFlag, + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectBegin() + pool.ExpectExec(expectedUpdateSQL). + WithArgs(true, pgxmock.AnyArg(), int32(42)). + WillReturnResult(pgxmock.NewResult("UPDATE", 1)) + pool.ExpectRollback() + }, + wantErr: errors.New("no authenticated user on context"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, pool := newTestStore(t) + tt.expectations(pool) + + err := store.SetFlag(tt.ctx, tt.flag) + if tt.wantErr != nil { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr.Error()) + } else { + require.NoError(t, err) + } + require.NoError(t, pool.ExpectationsWereMet()) + }) + } +} + diff --git a/server/featureflags/internal/handlers/handlers.go b/server/featureflags/internal/handlers/handlers.go new file mode 100644 index 000000000000..e9fb2eb8078d --- /dev/null +++ b/server/featureflags/internal/handlers/handlers.go @@ -0,0 +1,126 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +//go:generate go tool mockery + +import ( + "context" + "errors" + "log/slog" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/specterops/bloodhound/cmd/api/src/api" + "github.com/specterops/bloodhound/cmd/api/src/auth" + "github.com/specterops/bloodhound/cmd/api/src/bhctx" + "github.com/specterops/bloodhound/packages/go/bhlog/attr" + "github.com/specterops/bloodhound/packages/go/responses" + "github.com/specterops/bloodhound/server/featureflags/internal/services" +) + +// FeatureFlag defines the feature flag service boundary for the feature flag handlers package. +type FeatureFlag interface { + GetAllFlags(ctx context.Context) ([]services.FeatureFlag, error) + ToggleFlag(ctx context.Context, id int32, requestedBy string) (services.FeatureFlag, error) + IsEnabled(ctx context.Context, key string) (bool, error) +} + +// Handlers is a dependency injection container for app_config handlers +type Handlers struct { + featureFlag FeatureFlag +} + +// NewHandlersContainer initializes the Handlers dependency injection container +func NewHandlersContainer(featureFlag FeatureFlag) *Handlers { + return &Handlers{ + featureFlag: featureFlag, + } +} + + +// GetAllFlags returns the full list of feature flags as a JSON response. The +// handler delegates to the service layer to load the flags and serializes the +// result using the package's view builder. +func (s Handlers) GetAllFlags(response http.ResponseWriter, request *http.Request) { + var ctx = request.Context() + + flags, err := s.featureFlag.GetAllFlags(ctx) + if err != nil { + handleAppConfigError(request, response, err) + return + } + + responses.WriteBasic(ctx, BuildFeatureFlagsView(flags), http.StatusOK, response) +} + +// ToggleFlag toggles the Enabled state of the feature flag identified by the +// feature_id path parameter. The handler delegates to the service layer, which +// loads the existing flag, validates that it is user-updatable, flips its +// Enabled value, persists the result, and conditionally triggers an analysis +// request when the ADCS flag is being disabled. +// +// Authentication is enforced by the route middleware (RequirePermissions); if no +// user is present on the auth context here it indicates an unexpected internal +// state and a 500 is returned. +func (s Handlers) ToggleFlag(response http.ResponseWriter, request *http.Request) { + var ctx = request.Context() + + user, isUser := auth.GetUserFromAuthCtx(bhctx.FromRequest(request).AuthCtx) + if !isUser { + responses.WriteInternalServerError(ctx, errors.New("no user on auth context after authentication middleware"), response) + return + } + + rawFeatureID := mux.Vars(request)[api.URIPathVariableFeatureID] + + featureID, err := strconv.ParseInt(rawFeatureID, 10, 32) + if err != nil { + responses.WriteError(ctx, http.StatusBadRequest, api.ErrorResponseDetailsIDMalformed, response) + return + } + + flag, err := s.featureFlag.ToggleFlag(ctx, int32(featureID), user.ID.String()) + if err != nil { + handleAppConfigError(request, response, err) + return + } + + responses.WriteBasic(ctx, BuildFeatureFlagView(flag), http.StatusOK, response) +} + +// IsEnabled reports whether the feature flag identified by key is currently +// enabled. It is intended for in-process callers that need to gate behavior on +// a feature flag without going through the HTTP layer. +func (s Handlers) IsEnabled(ctx context.Context, key string) (bool, error) { + return s.featureFlag.IsEnabled(ctx, key) +} + +// handleAppConfigError maps service-layer errors to HTTP responses, translating +// known sentinel errors to their corresponding status codes and falling back to +// a logged 500 for anything unexpected. +func handleAppConfigError(request *http.Request, response http.ResponseWriter, err error) { + if errors.Is(err, services.ErrNotFound) { + responses.WriteError(request.Context(), http.StatusNotFound, services.ErrNotFound.Error(), response) + } else if errors.Is(err, services.ErrNotUserUpdatable) { + responses.WriteError(request.Context(), http.StatusForbidden, services.ErrNotUserUpdatable.Error(), response) + } else { + slog.Error("Unexpected database error", attr.Error(err)) + responses.WriteError(request.Context(), http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, response) + } +} diff --git a/server/featureflags/internal/handlers/handlers_test.go b/server/featureflags/internal/handlers/handlers_test.go new file mode 100644 index 000000000000..b113cfa6b069 --- /dev/null +++ b/server/featureflags/internal/handlers/handlers_test.go @@ -0,0 +1,282 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package handlers_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gofrs/uuid" + "github.com/gorilla/mux" + "github.com/specterops/bloodhound/cmd/api/src/api" + "github.com/specterops/bloodhound/cmd/api/src/auth" + "github.com/specterops/bloodhound/cmd/api/src/bhctx" + "github.com/specterops/bloodhound/cmd/api/src/model" + "github.com/specterops/bloodhound/server/featureflags/internal/handlers" + "github.com/specterops/bloodhound/server/featureflags/internal/handlers/mocks" + "github.com/specterops/bloodhound/server/featureflags/internal/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newAuthenticatedRequest returns an *http.Request whose context carries a +// bhctx.Context with the supplied user wired in as the auth Owner. This mirrors +// what the auth middleware does for real requests. +func newAuthenticatedRequest(t *testing.T, method, target string, userID uuid.UUID) *http.Request { + t.Helper() + req, err := http.NewRequest(method, target, nil) + require.NoError(t, err) + bhCtx := &bhctx.Context{ + AuthCtx: auth.Context{Owner: model.User{Unique: model.Unique{ID: userID}}}, + } + return bhctx.SetRequestContext(req, bhCtx) +} + +// withFeatureIDVar attaches the {feature_id} mux path variable to the supplied +// request so the handler under test can resolve it via mux.Vars. +func withFeatureIDVar(req *http.Request, featureID string) *http.Request { + return mux.SetURLVars(req, map[string]string{api.URIPathVariableFeatureID: featureID}) +} + +func TestHandlers_GetAllFlags(t *testing.T) { + var ( + unexpectedErr = errors.New("unexpected database failure") + flags = []services.FeatureFlag{ + {ID: 1, Key: services.FeatureOpenHoundSupport, Name: "OpenHound", Enabled: true}, + {ID: 2, Key: services.FeatureAlerts, Name: "Alerts", Enabled: false, UserUpdatable: true}, + } + ) + + tests := []struct { + name string + svcResult []services.FeatureFlag + svcErr error + wantStatus int + assertBody func(t *testing.T, body []byte) + }{ + { + name: "returns 200 with the feature flags view on success", + svcResult: flags, + wantStatus: http.StatusOK, + assertBody: func(t *testing.T, body []byte) { + var envelope struct { + Data handlers.FeatureFlagsView `json:"data"` + } + require.NoError(t, json.Unmarshal(body, &envelope)) + require.Len(t, envelope.Data, 2) + assert.Equal(t, int32(1), envelope.Data[0].ID) + assert.Equal(t, services.FeatureOpenHoundSupport, envelope.Data[0].Key) + assert.True(t, envelope.Data[1].UserUpdatable) + }, + }, + { + name: "returns an empty list when the service returns no flags", + svcResult: []services.FeatureFlag{}, + wantStatus: http.StatusOK, + assertBody: func(t *testing.T, body []byte) { + var envelope struct { + Data handlers.FeatureFlagsView `json:"data"` + } + require.NoError(t, json.Unmarshal(body, &envelope)) + assert.Empty(t, envelope.Data) + }, + }, + { + name: "returns 500 on unexpected service errors", + svcErr: unexpectedErr, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + featureFlagMock = mocks.NewMockFeatureFlag(t) + handlerSet = handlers.NewHandlersContainer(featureFlagMock) + recorder = httptest.NewRecorder() + request = httptest.NewRequest(http.MethodGet, "/api/v2/features", nil) + ) + + featureFlagMock.EXPECT().GetAllFlags(request.Context()).Return(tt.svcResult, tt.svcErr) + + handlerSet.GetAllFlags(recorder, request) + + assert.Equal(t, tt.wantStatus, recorder.Code) + if tt.assertBody != nil { + tt.assertBody(t, recorder.Body.Bytes()) + } + }) + } +} + +func TestHandlers_ToggleFlag(t *testing.T) { + var ( + userID = uuid.Must(uuid.NewV4()) + userIDString = userID.String() + toggled = services.FeatureFlag{ID: 5, Key: services.FeatureAlerts, Name: "Alerts", Enabled: true, UserUpdatable: true} + serviceErr = errors.New("db unavailable") + ) + + tests := []struct { + name string + featureID string + authenticated bool + expect func(m *mocks.MockFeatureFlag, ctx context.Context) + wantStatus int + assertBody func(t *testing.T, body []byte) + }{ + { + name: "returns 200 OK and the flag view on success", + featureID: "5", + authenticated: true, + expect: func(m *mocks.MockFeatureFlag, ctx context.Context) { + m.EXPECT().ToggleFlag(ctx, int32(5), userIDString).Return(toggled, nil) + }, + wantStatus: http.StatusOK, + assertBody: func(t *testing.T, body []byte) { + var envelope struct { + Data handlers.FeatureFlagView `json:"data"` + } + require.NoError(t, json.Unmarshal(body, &envelope)) + assert.Equal(t, int32(5), envelope.Data.ID) + assert.True(t, envelope.Data.Enabled) + }, + }, + { + name: "returns 500 when the request has no authenticated user", + featureID: "5", + authenticated: false, + wantStatus: http.StatusInternalServerError, + }, + { + name: "returns 400 when feature_id is not parseable as an int32", + featureID: "not-a-number", + authenticated: true, + wantStatus: http.StatusBadRequest, + }, + { + name: "returns 404 when the service reports ErrNotFound", + featureID: "5", + authenticated: true, + expect: func(m *mocks.MockFeatureFlag, ctx context.Context) { + m.EXPECT().ToggleFlag(ctx, int32(5), userIDString).Return(services.FeatureFlag{}, services.ErrNotFound) + }, + wantStatus: http.StatusNotFound, + }, + { + name: "returns 403 when the service reports ErrNotUserUpdatable", + featureID: "5", + authenticated: true, + expect: func(m *mocks.MockFeatureFlag, ctx context.Context) { + m.EXPECT().ToggleFlag(ctx, int32(5), userIDString).Return(services.FeatureFlag{}, services.ErrNotUserUpdatable) + }, + wantStatus: http.StatusForbidden, + }, + { + name: "returns 500 on unexpected service errors", + featureID: "5", + authenticated: true, + expect: func(m *mocks.MockFeatureFlag, ctx context.Context) { + m.EXPECT().ToggleFlag(ctx, int32(5), userIDString).Return(services.FeatureFlag{}, serviceErr) + }, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + featureFlagMock = mocks.NewMockFeatureFlag(t) + handlerSet = handlers.NewHandlersContainer(featureFlagMock) + recorder = httptest.NewRecorder() + request *http.Request + ) + + if tt.authenticated { + request = newAuthenticatedRequest(t, http.MethodPut, "/api/v2/features/"+tt.featureID+"/toggle", userID) + } else { + req, err := http.NewRequest(http.MethodPut, "/api/v2/features/"+tt.featureID+"/toggle", nil) + require.NoError(t, err) + request = req + } + request = withFeatureIDVar(request, tt.featureID) + + if tt.expect != nil { + tt.expect(featureFlagMock, request.Context()) + } + + handlerSet.ToggleFlag(recorder, request) + + assert.Equal(t, tt.wantStatus, recorder.Code) + if tt.assertBody != nil { + tt.assertBody(t, recorder.Body.Bytes()) + } + }) + } +} + +func TestHandlers_IsEnabled(t *testing.T) { + var ( + ctx = context.Background() + serviceErr = errors.New("connection refused") + ) + + tests := []struct { + name string + expect func(m *mocks.MockFeatureFlag) + wantEnabled bool + wantErr error + }{ + { + name: "delegates to the service and returns enabled=true", + expect: func(m *mocks.MockFeatureFlag) { + m.EXPECT().IsEnabled(ctx, services.FeatureOpenHoundSupport).Return(true, nil) + }, + wantEnabled: true, + }, + { + name: "propagates service errors", + expect: func(m *mocks.MockFeatureFlag) { + m.EXPECT().IsEnabled(ctx, services.FeatureOpenHoundSupport).Return(false, serviceErr) + }, + wantErr: serviceErr, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + featureFlagMock = mocks.NewMockFeatureFlag(t) + handlerSet = handlers.NewHandlersContainer(featureFlagMock) + ) + + tt.expect(featureFlagMock) + + enabled, err := handlerSet.IsEnabled(ctx, services.FeatureOpenHoundSupport) + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + assert.Equal(t, tt.wantEnabled, enabled) + }) + } +} diff --git a/server/featureflags/internal/handlers/mocks/featureflag.go b/server/featureflags/internal/handlers/mocks/featureflag.go new file mode 100644 index 000000000000..6d45c85befab --- /dev/null +++ b/server/featureflags/internal/handlers/mocks/featureflag.go @@ -0,0 +1,239 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/specterops/bloodhound/server/featureflags/internal/services" + mock "github.com/stretchr/testify/mock" +) + +// NewMockFeatureFlag creates a new instance of MockFeatureFlag. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockFeatureFlag(t interface { + mock.TestingT + Cleanup(func()) +}) *MockFeatureFlag { + mock := &MockFeatureFlag{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockFeatureFlag is an autogenerated mock type for the FeatureFlag type +type MockFeatureFlag struct { + mock.Mock +} + +type MockFeatureFlag_Expecter struct { + mock *mock.Mock +} + +func (_m *MockFeatureFlag) EXPECT() *MockFeatureFlag_Expecter { + return &MockFeatureFlag_Expecter{mock: &_m.Mock} +} + +// GetAllFlags provides a mock function for the type MockFeatureFlag +func (_mock *MockFeatureFlag) GetAllFlags(ctx context.Context) ([]services.FeatureFlag, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetAllFlags") + } + + var r0 []services.FeatureFlag + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) ([]services.FeatureFlag, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) []services.FeatureFlag); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]services.FeatureFlag) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockFeatureFlag_GetAllFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllFlags' +type MockFeatureFlag_GetAllFlags_Call struct { + *mock.Call +} + +// GetAllFlags is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockFeatureFlag_Expecter) GetAllFlags(ctx interface{}) *MockFeatureFlag_GetAllFlags_Call { + return &MockFeatureFlag_GetAllFlags_Call{Call: _e.mock.On("GetAllFlags", ctx)} +} + +func (_c *MockFeatureFlag_GetAllFlags_Call) Run(run func(ctx context.Context)) *MockFeatureFlag_GetAllFlags_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockFeatureFlag_GetAllFlags_Call) Return(featureFlags []services.FeatureFlag, err error) *MockFeatureFlag_GetAllFlags_Call { + _c.Call.Return(featureFlags, err) + return _c +} + +func (_c *MockFeatureFlag_GetAllFlags_Call) RunAndReturn(run func(ctx context.Context) ([]services.FeatureFlag, error)) *MockFeatureFlag_GetAllFlags_Call { + _c.Call.Return(run) + return _c +} + +// IsEnabled provides a mock function for the type MockFeatureFlag +func (_mock *MockFeatureFlag) IsEnabled(ctx context.Context, key string) (bool, error) { + ret := _mock.Called(ctx, key) + + if len(ret) == 0 { + panic("no return value specified for IsEnabled") + } + + var r0 bool + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok { + return returnFunc(ctx, key) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { + r0 = returnFunc(ctx, key) + } else { + r0 = ret.Get(0).(bool) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, key) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockFeatureFlag_IsEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsEnabled' +type MockFeatureFlag_IsEnabled_Call struct { + *mock.Call +} + +// IsEnabled is a helper method to define mock.On call +// - ctx context.Context +// - key string +func (_e *MockFeatureFlag_Expecter) IsEnabled(ctx interface{}, key interface{}) *MockFeatureFlag_IsEnabled_Call { + return &MockFeatureFlag_IsEnabled_Call{Call: _e.mock.On("IsEnabled", ctx, key)} +} + +func (_c *MockFeatureFlag_IsEnabled_Call) Run(run func(ctx context.Context, key string)) *MockFeatureFlag_IsEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockFeatureFlag_IsEnabled_Call) Return(b bool, err error) *MockFeatureFlag_IsEnabled_Call { + _c.Call.Return(b, err) + return _c +} + +func (_c *MockFeatureFlag_IsEnabled_Call) RunAndReturn(run func(ctx context.Context, key string) (bool, error)) *MockFeatureFlag_IsEnabled_Call { + _c.Call.Return(run) + return _c +} + +// ToggleFlag provides a mock function for the type MockFeatureFlag +func (_mock *MockFeatureFlag) ToggleFlag(ctx context.Context, id int32, requestedBy string) (services.FeatureFlag, error) { + ret := _mock.Called(ctx, id, requestedBy) + + if len(ret) == 0 { + panic("no return value specified for ToggleFlag") + } + + var r0 services.FeatureFlag + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int32, string) (services.FeatureFlag, error)); ok { + return returnFunc(ctx, id, requestedBy) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int32, string) services.FeatureFlag); ok { + r0 = returnFunc(ctx, id, requestedBy) + } else { + r0 = ret.Get(0).(services.FeatureFlag) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, int32, string) error); ok { + r1 = returnFunc(ctx, id, requestedBy) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockFeatureFlag_ToggleFlag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ToggleFlag' +type MockFeatureFlag_ToggleFlag_Call struct { + *mock.Call +} + +// ToggleFlag is a helper method to define mock.On call +// - ctx context.Context +// - id int32 +// - requestedBy string +func (_e *MockFeatureFlag_Expecter) ToggleFlag(ctx interface{}, id interface{}, requestedBy interface{}) *MockFeatureFlag_ToggleFlag_Call { + return &MockFeatureFlag_ToggleFlag_Call{Call: _e.mock.On("ToggleFlag", ctx, id, requestedBy)} +} + +func (_c *MockFeatureFlag_ToggleFlag_Call) Run(run func(ctx context.Context, id int32, requestedBy string)) *MockFeatureFlag_ToggleFlag_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int32 + if args[1] != nil { + arg1 = args[1].(int32) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockFeatureFlag_ToggleFlag_Call) Return(featureFlag services.FeatureFlag, err error) *MockFeatureFlag_ToggleFlag_Call { + _c.Call.Return(featureFlag, err) + return _c +} + +func (_c *MockFeatureFlag_ToggleFlag_Call) RunAndReturn(run func(ctx context.Context, id int32, requestedBy string) (services.FeatureFlag, error)) *MockFeatureFlag_ToggleFlag_Call { + _c.Call.Return(run) + return _c +} diff --git a/server/featureflags/internal/handlers/views.go b/server/featureflags/internal/handlers/views.go new file mode 100644 index 000000000000..1d14acc9fd43 --- /dev/null +++ b/server/featureflags/internal/handlers/views.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "time" + + "github.com/specterops/bloodhound/server/featureflags/internal/services" +) + +type FeatureFlagView struct { + ID int32 `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + UserUpdatable bool `json:"user_updatable"` +} + +func BuildFeatureFlagView(tf services.FeatureFlag) FeatureFlagView { + return FeatureFlagView{ + ID: tf.ID, + CreatedAt: tf.CreatedAt, + UpdatedAt: tf.UpdatedAt, + Key: tf.Key, + Name: tf.Name, + Description: tf.Description, + Enabled: tf.Enabled, + UserUpdatable: tf.UserUpdatable, + } +} + +func (s FeatureFlagView) JSONView() ([]byte, error) { return json.Marshal(s) } + +type FeatureFlagsView []FeatureFlagView + +func BuildFeatureFlagsView(flags []services.FeatureFlag) FeatureFlagsView { + views := make(FeatureFlagsView, 0, len(flags)) + for _, flag := range flags { + views = append(views, BuildFeatureFlagView(flag)) + } + return views +} + +func (s FeatureFlagsView) JSONView() ([]byte, error) { return json.Marshal(s) } diff --git a/server/featureflags/internal/routes/routes.go b/server/featureflags/internal/routes/routes.go new file mode 100644 index 000000000000..a5cbed42c28f --- /dev/null +++ b/server/featureflags/internal/routes/routes.go @@ -0,0 +1,14 @@ +package routes + +import ( + "github.com/specterops/bloodhound/cmd/api/src/api/router" + "github.com/specterops/bloodhound/cmd/api/src/auth" + "github.com/specterops/bloodhound/server/featureflags/internal/handlers" +) + +func Register(routerInst *router.Router, handlers *handlers.Handlers) { + var permissions = auth.Permissions() + + routerInst.GET("/api/v2/features", handlers.GetAllFlags).RequirePermissions(permissions.AppReadApplicationConfiguration) + routerInst.PUT("/api/v2/features/{feature_id}/toggle", handlers.ToggleFlag).RequirePermissions(permissions.AppWriteApplicationConfiguration) +} diff --git a/server/featureflags/internal/routes/routes_test.go b/server/featureflags/internal/routes/routes_test.go new file mode 100644 index 000000000000..f0b58b849de1 --- /dev/null +++ b/server/featureflags/internal/routes/routes_test.go @@ -0,0 +1,95 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package routes_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/specterops/bloodhound/cmd/api/src/api/router" + "github.com/specterops/bloodhound/cmd/api/src/auth" + "github.com/specterops/bloodhound/cmd/api/src/config" + "github.com/specterops/bloodhound/server/featureflags/internal/handlers" + "github.com/specterops/bloodhound/server/featureflags/internal/handlers/mocks" + "github.com/specterops/bloodhound/server/featureflags/internal/routes" + "github.com/stretchr/testify/assert" +) + +func TestRegister(t *testing.T) { + var ( + cfg = config.Configuration{} + authorizer = auth.NewAuthorizer(nil) + routerInst = router.NewRouter(cfg, authorizer, "") + featureFlagMock = mocks.NewMockFeatureFlag(t) + handlerSet = handlers.NewHandlersContainer(featureFlagMock) + ) + + routes.Register(&routerInst, handlerSet) + + muxRouter := routerInst.MuxRouter() + + for _, tc := range []struct { + method string + path string + }{ + {http.MethodGet, "/api/v2/features"}, + {http.MethodPut, "/api/v2/features/1/toggle"}, + } { + req := httptest.NewRequest(tc.method, tc.path, nil) + var match mux.RouteMatch + assert.True(t, muxRouter.Match(req, &match), "%s %s route should be registered", tc.method, tc.path) + } +} + +// TestRegister_RoutesRequireAuthentication dispatches real requests through the wired +// router to verify that the registered routes are guarded by authentication middleware. +// The handlers themselves trust the middleware to enforce this contract; if the route +// wireup ever loses RequirePermissions/RequireAuth, this test will fail. +func TestRegister_RoutesRequireAuthentication(t *testing.T) { + var ( + cfg = config.Configuration{} + authorizer = auth.NewAuthorizer(nil) + routerInst = router.NewRouter(cfg, authorizer, "") + featureFlagMock = mocks.NewMockFeatureFlag(t) + handlerSet = handlers.NewHandlersContainer(featureFlagMock) + ) + + routes.Register(&routerInst, handlerSet) + handler := routerInst.Handler() + + for _, tc := range []struct { + method string + path string + }{ + {http.MethodGet, "/api/v2/features"}, + {http.MethodPut, "/api/v2/features/1/toggle"}, + } { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + var ( + request = httptest.NewRequest(tc.method, tc.path, nil) + recorder = httptest.NewRecorder() + ) + + handler.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusUnauthorized, recorder.Code, + "unauthenticated %s %s must be rejected by middleware before reaching the handler", tc.method, tc.path) + }) + } +} diff --git a/server/featureflags/internal/services/mocks/database.go b/server/featureflags/internal/services/mocks/database.go index 3a655dfc46e6..d7ff75a656d9 100644 --- a/server/featureflags/internal/services/mocks/database.go +++ b/server/featureflags/internal/services/mocks/database.go @@ -1,18 +1,3 @@ -// Copyright 2026 Specter Ops, Inc. -// -// Licensed under the Apache License, Version 2.0 -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// SPDX-License-Identifier: Apache-2.0 // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify @@ -53,6 +38,134 @@ func (_m *MockDatabase) EXPECT() *MockDatabase_Expecter { return &MockDatabase_Expecter{mock: &_m.Mock} } +// GetAllFlags provides a mock function for the type MockDatabase +func (_mock *MockDatabase) GetAllFlags(ctx context.Context) ([]services.FeatureFlag, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetAllFlags") + } + + var r0 []services.FeatureFlag + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) ([]services.FeatureFlag, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) []services.FeatureFlag); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]services.FeatureFlag) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockDatabase_GetAllFlags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAllFlags' +type MockDatabase_GetAllFlags_Call struct { + *mock.Call +} + +// GetAllFlags is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockDatabase_Expecter) GetAllFlags(ctx interface{}) *MockDatabase_GetAllFlags_Call { + return &MockDatabase_GetAllFlags_Call{Call: _e.mock.On("GetAllFlags", ctx)} +} + +func (_c *MockDatabase_GetAllFlags_Call) Run(run func(ctx context.Context)) *MockDatabase_GetAllFlags_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MockDatabase_GetAllFlags_Call) Return(featureFlags []services.FeatureFlag, err error) *MockDatabase_GetAllFlags_Call { + _c.Call.Return(featureFlags, err) + return _c +} + +func (_c *MockDatabase_GetAllFlags_Call) RunAndReturn(run func(ctx context.Context) ([]services.FeatureFlag, error)) *MockDatabase_GetAllFlags_Call { + _c.Call.Return(run) + return _c +} + +// GetFlagByID provides a mock function for the type MockDatabase +func (_mock *MockDatabase) GetFlagByID(ctx context.Context, id int32) (services.FeatureFlag, error) { + ret := _mock.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetFlagByID") + } + + var r0 services.FeatureFlag + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int32) (services.FeatureFlag, error)); ok { + return returnFunc(ctx, id) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int32) services.FeatureFlag); ok { + r0 = returnFunc(ctx, id) + } else { + r0 = ret.Get(0).(services.FeatureFlag) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, int32) error); ok { + r1 = returnFunc(ctx, id) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockDatabase_GetFlagByID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetFlagByID' +type MockDatabase_GetFlagByID_Call struct { + *mock.Call +} + +// GetFlagByID is a helper method to define mock.On call +// - ctx context.Context +// - id int32 +func (_e *MockDatabase_Expecter) GetFlagByID(ctx interface{}, id interface{}) *MockDatabase_GetFlagByID_Call { + return &MockDatabase_GetFlagByID_Call{Call: _e.mock.On("GetFlagByID", ctx, id)} +} + +func (_c *MockDatabase_GetFlagByID_Call) Run(run func(ctx context.Context, id int32)) *MockDatabase_GetFlagByID_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int32 + if args[1] != nil { + arg1 = args[1].(int32) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockDatabase_GetFlagByID_Call) Return(featureFlag services.FeatureFlag, err error) *MockDatabase_GetFlagByID_Call { + _c.Call.Return(featureFlag, err) + return _c +} + +func (_c *MockDatabase_GetFlagByID_Call) RunAndReturn(run func(ctx context.Context, id int32) (services.FeatureFlag, error)) *MockDatabase_GetFlagByID_Call { + _c.Call.Return(run) + return _c +} + // GetFlagByKey provides a mock function for the type MockDatabase func (_mock *MockDatabase) GetFlagByKey(ctx context.Context, key string) (services.FeatureFlag, error) { ret := _mock.Called(ctx, key) @@ -118,3 +231,60 @@ func (_c *MockDatabase_GetFlagByKey_Call) RunAndReturn(run func(ctx context.Cont _c.Call.Return(run) return _c } + +// SetFlag provides a mock function for the type MockDatabase +func (_mock *MockDatabase) SetFlag(ctx context.Context, featureFlag services.FeatureFlag) error { + ret := _mock.Called(ctx, featureFlag) + + if len(ret) == 0 { + panic("no return value specified for SetFlag") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, services.FeatureFlag) error); ok { + r0 = returnFunc(ctx, featureFlag) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockDatabase_SetFlag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetFlag' +type MockDatabase_SetFlag_Call struct { + *mock.Call +} + +// SetFlag is a helper method to define mock.On call +// - ctx context.Context +// - featureFlag services.FeatureFlag +func (_e *MockDatabase_Expecter) SetFlag(ctx interface{}, featureFlag interface{}) *MockDatabase_SetFlag_Call { + return &MockDatabase_SetFlag_Call{Call: _e.mock.On("SetFlag", ctx, featureFlag)} +} + +func (_c *MockDatabase_SetFlag_Call) Run(run func(ctx context.Context, featureFlag services.FeatureFlag)) *MockDatabase_SetFlag_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 services.FeatureFlag + if args[1] != nil { + arg1 = args[1].(services.FeatureFlag) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockDatabase_SetFlag_Call) Return(err error) *MockDatabase_SetFlag_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockDatabase_SetFlag_Call) RunAndReturn(run func(ctx context.Context, featureFlag services.FeatureFlag) error) *MockDatabase_SetFlag_Call { + _c.Call.Return(run) + return _c +} diff --git a/server/featureflags/internal/services/services.go b/server/featureflags/internal/services/services.go index a7f31b152a79..694744229c16 100644 --- a/server/featureflags/internal/services/services.go +++ b/server/featureflags/internal/services/services.go @@ -21,6 +21,8 @@ import ( "context" "errors" "time" + + "github.com/specterops/bloodhound/cmd/api/src/model" ) // Feature-flag keys referenced by BHE feature slices. These mirror the keys @@ -32,7 +34,10 @@ const ( ) // ErrNotFound indicates that no feature flag exists for the requested key. -var ErrNotFound = errors.New("feature flag not found") +var ( + ErrNotFound = errors.New("feature flag not found") + ErrNotUserUpdatable = errors.New("feature flag is not user updatable") +) // FeatureFlag is the domain representation of a row in the feature_flags table. type FeatureFlag struct { @@ -46,12 +51,24 @@ type FeatureFlag struct { UserUpdatable bool } +// TODO: once the audit log middleware is instantuated, this should be removed +func (s FeatureFlag) AuditData() model.AuditData { + return model.AuditData{ + "name": s.Name, + "key": s.Key, + "enabled": s.Enabled, + } +} + // Database describes the persistence capabilities the feature-flag Service // requires. Implementations translate driver-level not-found errors into // ErrNotFound so the Service can reason about them in domain terms. It is the // single port through which a database implementation is injected. type Database interface { GetFlagByKey(ctx context.Context, key string) (FeatureFlag, error) + GetFlagByID(ctx context.Context, id int32) (FeatureFlag, error) + GetAllFlags(ctx context.Context) ([]FeatureFlag, error) + SetFlag(ctx context.Context, featureFlag FeatureFlag) error } // Service implements feature-flag use cases on top of a Database implementation. @@ -83,3 +100,28 @@ func (s *Service) IsEnabled(ctx context.Context, key string) (bool, error) { } return flag.Enabled, nil } + +// GetAllFlags returns all the feature flags +func (s *Service) GetAllFlags(ctx context.Context) ([]FeatureFlag, error) { + return s.db.GetAllFlags(ctx) +} + +// ToggleFlag enables/disables the feature flag by the feature flag id +func (s *Service) ToggleFlag(ctx context.Context, id int32, requestedBy string) (FeatureFlag, error) { + flag, err := s.db.GetFlagByID(ctx, id) + if err != nil { + return flag, err + } + + if !flag.UserUpdatable { + return flag, ErrNotUserUpdatable + } + + flag.Enabled = !flag.Enabled + + if err := s.db.SetFlag(ctx, flag); err != nil { + return flag, err + } + + return flag, nil +} diff --git a/server/featureflags/internal/services/services_test.go b/server/featureflags/internal/services/services_test.go index 6ca31b0bd33c..7484622f4b05 100644 --- a/server/featureflags/internal/services/services_test.go +++ b/server/featureflags/internal/services/services_test.go @@ -37,6 +37,18 @@ func (f fakeFlagDatabase) GetFlagByKey(_ context.Context, _ string) (services.Fe return f.flag, f.err } +func (f fakeFlagDatabase) GetFlagByID(_ context.Context, _ int32) (services.FeatureFlag, error) { + return f.flag, f.err +} + +func (f fakeFlagDatabase) GetAllFlags(_ context.Context) ([]services.FeatureFlag, error) { + return nil, f.err +} + +func (f fakeFlagDatabase) SetFlag(_ context.Context, _ services.FeatureFlag) error { + return f.err +} + func TestNewService(t *testing.T) { mockDb := mocks.NewMockDatabase(t) assert.NotNil(t, services.NewService(mockDb)) @@ -112,3 +124,130 @@ func TestService_IsEnabled(t *testing.T) { }) } } + +func TestService_GetAllFlags(t *testing.T) { + var ( + ctx = context.Background() + unexpectedErr = errors.New("connection refused") + expected = []services.FeatureFlag{ + {ID: 1, Key: services.FeatureOpenHoundSupport, Enabled: true, UserUpdatable: true}, + {ID: 2, Key: services.FeatureAlerts, Enabled: false, UserUpdatable: false}, + } + ) + + tests := []struct { + name string + dbResult []services.FeatureFlag + dbErr error + wantResult []services.FeatureFlag + wantErr error + }{ + { + name: "returns all flags on success", + dbResult: expected, + wantResult: expected, + }, + { + name: "propagates database errors", + dbErr: unexpectedErr, + wantErr: unexpectedErr, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + databaseMock = mocks.NewMockDatabase(t) + svc = services.NewService(databaseMock) + ) + + databaseMock.EXPECT().GetAllFlags(ctx).Return(tt.dbResult, tt.dbErr) + + got, err := svc.GetAllFlags(ctx) + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantResult, got) + } + }) + } +} + +func TestService_ToggleFlag(t *testing.T) { + var ( + ctx = context.Background() + requester = "test-user" + unexpectedErr = errors.New("connection refused") + setFlagErr = errors.New("set flag failed") + updatableFlag = services.FeatureFlag{ + ID: 7, + Key: services.FeatureOpenHoundSupport, + Enabled: false, + UserUpdatable: true, + } + nonUpdatableFlag = services.FeatureFlag{ + ID: 8, + Key: services.FeatureAlerts, + Enabled: true, + UserUpdatable: false, + } + ) + + t.Run("toggles the flag and returns the updated value", func(t *testing.T) { + var ( + databaseMock = mocks.NewMockDatabase(t) + svc = services.NewService(databaseMock) + toggled = updatableFlag + ) + toggled.Enabled = !updatableFlag.Enabled + + databaseMock.EXPECT().GetFlagByID(ctx, updatableFlag.ID).Return(updatableFlag, nil) + databaseMock.EXPECT().SetFlag(ctx, toggled).Return(nil) + + got, err := svc.ToggleFlag(ctx, updatableFlag.ID, requester) + require.NoError(t, err) + assert.Equal(t, toggled, got) + }) + + t.Run("returns ErrNotUserUpdatable when the flag is not user updatable", func(t *testing.T) { + var ( + databaseMock = mocks.NewMockDatabase(t) + svc = services.NewService(databaseMock) + ) + + databaseMock.EXPECT().GetFlagByID(ctx, nonUpdatableFlag.ID).Return(nonUpdatableFlag, nil) + + got, err := svc.ToggleFlag(ctx, nonUpdatableFlag.ID, requester) + assert.ErrorIs(t, err, services.ErrNotUserUpdatable) + assert.Equal(t, nonUpdatableFlag, got) + }) + + t.Run("propagates errors from GetFlagByID", func(t *testing.T) { + var ( + databaseMock = mocks.NewMockDatabase(t) + svc = services.NewService(databaseMock) + ) + + databaseMock.EXPECT().GetFlagByID(ctx, int32(99)).Return(services.FeatureFlag{}, unexpectedErr) + + _, err := svc.ToggleFlag(ctx, 99, requester) + assert.ErrorIs(t, err, unexpectedErr) + }) + + t.Run("propagates errors from SetFlag", func(t *testing.T) { + var ( + databaseMock = mocks.NewMockDatabase(t) + svc = services.NewService(databaseMock) + toggled = updatableFlag + ) + toggled.Enabled = !updatableFlag.Enabled + + databaseMock.EXPECT().GetFlagByID(ctx, updatableFlag.ID).Return(updatableFlag, nil) + databaseMock.EXPECT().SetFlag(ctx, toggled).Return(setFlagErr) + + _, err := svc.ToggleFlag(ctx, updatableFlag.ID, requester) + assert.ErrorIs(t, err, setFlagErr) + }) +} + diff --git a/server/featureflags/mocks/featureflagrequestadapter.go b/server/featureflags/mocks/featureflagrequestadapter.go new file mode 100644 index 000000000000..046b5f510593 --- /dev/null +++ b/server/featureflags/mocks/featureflagrequestadapter.go @@ -0,0 +1,151 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + "net/http" + + mock "github.com/stretchr/testify/mock" +) + +// NewMockFeatureFlagRequestAdapter creates a new instance of MockFeatureFlagRequestAdapter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockFeatureFlagRequestAdapter(t interface { + mock.TestingT + Cleanup(func()) +}) *MockFeatureFlagRequestAdapter { + mock := &MockFeatureFlagRequestAdapter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockFeatureFlagRequestAdapter is an autogenerated mock type for the FeatureFlagRequestAdapter type +type MockFeatureFlagRequestAdapter struct { + mock.Mock +} + +type MockFeatureFlagRequestAdapter_Expecter struct { + mock *mock.Mock +} + +func (_m *MockFeatureFlagRequestAdapter) EXPECT() *MockFeatureFlagRequestAdapter_Expecter { + return &MockFeatureFlagRequestAdapter_Expecter{mock: &_m.Mock} +} + +// IsEnabled provides a mock function for the type MockFeatureFlagRequestAdapter +func (_mock *MockFeatureFlagRequestAdapter) IsEnabled(ctx context.Context, key string) (bool, error) { + ret := _mock.Called(ctx, key) + + if len(ret) == 0 { + panic("no return value specified for IsEnabled") + } + + var r0 bool + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok { + return returnFunc(ctx, key) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok { + r0 = returnFunc(ctx, key) + } else { + r0 = ret.Get(0).(bool) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, key) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockFeatureFlagRequestAdapter_IsEnabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsEnabled' +type MockFeatureFlagRequestAdapter_IsEnabled_Call struct { + *mock.Call +} + +// IsEnabled is a helper method to define mock.On call +// - ctx context.Context +// - key string +func (_e *MockFeatureFlagRequestAdapter_Expecter) IsEnabled(ctx interface{}, key interface{}) *MockFeatureFlagRequestAdapter_IsEnabled_Call { + return &MockFeatureFlagRequestAdapter_IsEnabled_Call{Call: _e.mock.On("IsEnabled", ctx, key)} +} + +func (_c *MockFeatureFlagRequestAdapter_IsEnabled_Call) Run(run func(ctx context.Context, key string)) *MockFeatureFlagRequestAdapter_IsEnabled_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockFeatureFlagRequestAdapter_IsEnabled_Call) Return(b bool, err error) *MockFeatureFlagRequestAdapter_IsEnabled_Call { + _c.Call.Return(b, err) + return _c +} + +func (_c *MockFeatureFlagRequestAdapter_IsEnabled_Call) RunAndReturn(run func(ctx context.Context, key string) (bool, error)) *MockFeatureFlagRequestAdapter_IsEnabled_Call { + _c.Call.Return(run) + return _c +} + +// ToggleFlag provides a mock function for the type MockFeatureFlagRequestAdapter +func (_mock *MockFeatureFlagRequestAdapter) ToggleFlag(response http.ResponseWriter, request *http.Request) { + _mock.Called(response, request) + return +} + +// MockFeatureFlagRequestAdapter_ToggleFlag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ToggleFlag' +type MockFeatureFlagRequestAdapter_ToggleFlag_Call struct { + *mock.Call +} + +// ToggleFlag is a helper method to define mock.On call +// - response http.ResponseWriter +// - request *http.Request +func (_e *MockFeatureFlagRequestAdapter_Expecter) ToggleFlag(response interface{}, request interface{}) *MockFeatureFlagRequestAdapter_ToggleFlag_Call { + return &MockFeatureFlagRequestAdapter_ToggleFlag_Call{Call: _e.mock.On("ToggleFlag", response, request)} +} + +func (_c *MockFeatureFlagRequestAdapter_ToggleFlag_Call) Run(run func(response http.ResponseWriter, request *http.Request)) *MockFeatureFlagRequestAdapter_ToggleFlag_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 http.ResponseWriter + if args[0] != nil { + arg0 = args[0].(http.ResponseWriter) + } + var arg1 *http.Request + if args[1] != nil { + arg1 = args[1].(*http.Request) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockFeatureFlagRequestAdapter_ToggleFlag_Call) Return() *MockFeatureFlagRequestAdapter_ToggleFlag_Call { + _c.Call.Return() + return _c +} + +func (_c *MockFeatureFlagRequestAdapter_ToggleFlag_Call) RunAndReturn(run func(response http.ResponseWriter, request *http.Request)) *MockFeatureFlagRequestAdapter_ToggleFlag_Call { + _c.Run(run) + return _c +} diff --git a/server/modules/modules.go b/server/modules/modules.go index 4c710587704e..7074d0a3e535 100644 --- a/server/modules/modules.go +++ b/server/modules/modules.go @@ -23,6 +23,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "github.com/specterops/bloodhound/cmd/api/src/api/router" "github.com/specterops/bloodhound/server/analysis" + "github.com/specterops/bloodhound/server/featureflags" ) // Deps carries the shared infrastructure that feature modules need in order to @@ -46,4 +47,5 @@ func Register(deps Deps) { } analysis.Register(deps.Router, deps.Pool) + featureflags.Register(deps.Router, deps.Pool) } diff --git a/server/modules/modules_test.go b/server/modules/modules_test.go index 72d7900aaab2..d5bb020f184c 100644 --- a/server/modules/modules_test.go +++ b/server/modules/modules_test.go @@ -54,11 +54,11 @@ func TestRegister_PanicsOnNilPool(t *testing.T) { }) } -// TestRegister_WiresAnalysisRoutes verifies that the composition root correctly -// attaches the analysis module routes to the shared router. Matching a -// representative route proves that Register successfully delegated to the -// feature module. -func TestRegister_WiresAnalysisRoutes(t *testing.T) { +// TestRegister_WiresFeatureModuleRoutes verifies that the composition root +// correctly attaches each feature module's routes to the shared router. +// Matching a representative route per module proves that Register successfully +// delegated to the feature module. +func TestRegister_WiresFeatureModuleRoutes(t *testing.T) { var ( cfg = config.Configuration{} authorizer = auth.NewAuthorizer(nil) @@ -71,11 +71,24 @@ func TestRegister_WiresAnalysisRoutes(t *testing.T) { modules.Register(deps) - var ( - muxRouter = routerInst.MuxRouter() - request = httptest.NewRequest(http.MethodGet, "/api/v2/analysis/status", nil) - match mux.RouteMatch - ) + muxRouter := routerInst.MuxRouter() - assert.True(t, muxRouter.Match(request, &match), "analysis route should be registered by Register") + for _, tc := range []struct { + name string + method string + path string + }{ + {"analysis status", http.MethodGet, "/api/v2/analysis/status"}, + {"feature flags list", http.MethodGet, "/api/v2/features"}, + {"feature flag toggle", http.MethodPut, "/api/v2/features/1/toggle"}, + } { + t.Run(tc.name, func(t *testing.T) { + var ( + request = httptest.NewRequest(tc.method, tc.path, nil) + match mux.RouteMatch + ) + + assert.True(t, muxRouter.Match(request, &match), "%s %s route should be registered by Register", tc.method, tc.path) + }) + } } From 4e5b61c70c99247436da62b99402d249fd5b2079 Mon Sep 17 00:00:00 2001 From: Flechas Bentley Date: Thu, 25 Jun 2026 11:55:18 -0500 Subject: [PATCH 2/7] jpfcr --- .../internal/handlers/mocks/analysis.go | 15 +++++++++++++ .../internal/services/mocks/database.go | 15 +++++++++++++ .../analysis/mocks/analysisrequestadapter.go | 15 +++++++++++++ server/featureflags/featureflags_e2e_test.go | 5 ++--- .../featureflags/internal/appdb/appdb_test.go | 21 +++++++++---------- .../internal/handlers/handlers.go | 1 - .../internal/handlers/mocks/featureflag.go | 15 +++++++++++++ .../featureflags/internal/handlers/views.go | 15 +++++++++++++ server/featureflags/internal/routes/routes.go | 15 +++++++++++++ .../internal/routes/routes_test.go | 16 +++++++------- .../internal/services/mocks/database.go | 15 +++++++++++++ .../internal/services/services_test.go | 1 - .../mocks/featureflagrequestadapter.go | 15 +++++++++++++ 13 files changed, 140 insertions(+), 24 deletions(-) diff --git a/server/analysis/internal/handlers/mocks/analysis.go b/server/analysis/internal/handlers/mocks/analysis.go index 735bce272e17..1fb8e1a2718a 100644 --- a/server/analysis/internal/handlers/mocks/analysis.go +++ b/server/analysis/internal/handlers/mocks/analysis.go @@ -1,3 +1,18 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify diff --git a/server/analysis/internal/services/mocks/database.go b/server/analysis/internal/services/mocks/database.go index e87b72e7dc85..b52b9f8cc10b 100644 --- a/server/analysis/internal/services/mocks/database.go +++ b/server/analysis/internal/services/mocks/database.go @@ -1,3 +1,18 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify diff --git a/server/analysis/mocks/analysisrequestadapter.go b/server/analysis/mocks/analysisrequestadapter.go index 6bdad1ceb35c..f154dbf182ba 100644 --- a/server/analysis/mocks/analysisrequestadapter.go +++ b/server/analysis/mocks/analysisrequestadapter.go @@ -1,3 +1,18 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify diff --git a/server/featureflags/featureflags_e2e_test.go b/server/featureflags/featureflags_e2e_test.go index 2c3d813310b6..105f158c7bdd 100644 --- a/server/featureflags/featureflags_e2e_test.go +++ b/server/featureflags/featureflags_e2e_test.go @@ -240,7 +240,7 @@ func TestToggleFlag(t *testing.T) { t.Run("returns 200 OK and flips the flag when it is user-updatable", func(t *testing.T) { var ( - server = newServer(t) + server = newServer(t) featureID = seedFeatureFlag(t, ctx, db.Pool(), "e2e_toggle_flag", "E2E Toggle Flag", false, true) ) @@ -258,7 +258,7 @@ func TestToggleFlag(t *testing.T) { t.Run("returns 403 Forbidden when the flag is not user-updatable", func(t *testing.T) { var ( - server = newServer(t) + server = newServer(t) featureID = seedFeatureFlag(t, ctx, db.Pool(), "e2e_locked_flag", "E2E Locked Flag", false, false) ) @@ -279,4 +279,3 @@ func TestToggleFlag(t *testing.T) { assert.Equal(t, http.StatusNotFound, resp.StatusCode) }) } - diff --git a/server/featureflags/internal/appdb/appdb_test.go b/server/featureflags/internal/appdb/appdb_test.go index d9bb4f3418d8..cae0db3ae91d 100644 --- a/server/featureflags/internal/appdb/appdb_test.go +++ b/server/featureflags/internal/appdb/appdb_test.go @@ -290,16 +290,16 @@ func TestStore_SetFlag(t *testing.T) { WillReturnResult(pgxmock.NewResult("UPDATE", 1)) pool.ExpectExec(expectedAuditInsertSQL). WithArgs( - pgxmock.AnyArg(), // created_at - userID.String(), // actor_id - "test-user", // actor_name - "", // actor_email - string(model.AuditLogActionToggleEarlyAccessFeatureFlag), // action - pgxmock.AnyArg(), // fields (json) - "test-request", // request_id - "127.0.0.1", // source_ip_address - string(model.AuditLogStatusSuccess), // status - pgxmock.AnyArg(), // commit_id + pgxmock.AnyArg(), // created_at + userID.String(), // actor_id + "test-user", // actor_name + "", // actor_email + string(model.AuditLogActionToggleEarlyAccessFeatureFlag), // action + pgxmock.AnyArg(), // fields (json) + "test-request", // request_id + "127.0.0.1", // source_ip_address + string(model.AuditLogStatusSuccess), // status + pgxmock.AnyArg(), // commit_id ). WillReturnResult(pgxmock.NewResult("INSERT", 1)) pool.ExpectCommit() @@ -377,4 +377,3 @@ func TestStore_SetFlag(t *testing.T) { }) } } - diff --git a/server/featureflags/internal/handlers/handlers.go b/server/featureflags/internal/handlers/handlers.go index e9fb2eb8078d..0cc7ae72b059 100644 --- a/server/featureflags/internal/handlers/handlers.go +++ b/server/featureflags/internal/handlers/handlers.go @@ -53,7 +53,6 @@ func NewHandlersContainer(featureFlag FeatureFlag) *Handlers { } } - // GetAllFlags returns the full list of feature flags as a JSON response. The // handler delegates to the service layer to load the flags and serializes the // result using the package's view builder. diff --git a/server/featureflags/internal/handlers/mocks/featureflag.go b/server/featureflags/internal/handlers/mocks/featureflag.go index 6d45c85befab..3d735f0e800a 100644 --- a/server/featureflags/internal/handlers/mocks/featureflag.go +++ b/server/featureflags/internal/handlers/mocks/featureflag.go @@ -1,3 +1,18 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify diff --git a/server/featureflags/internal/handlers/views.go b/server/featureflags/internal/handlers/views.go index 1d14acc9fd43..bc5fbcec00dd 100644 --- a/server/featureflags/internal/handlers/views.go +++ b/server/featureflags/internal/handlers/views.go @@ -1,3 +1,18 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 package handlers import ( diff --git a/server/featureflags/internal/routes/routes.go b/server/featureflags/internal/routes/routes.go index a5cbed42c28f..3b023eadc001 100644 --- a/server/featureflags/internal/routes/routes.go +++ b/server/featureflags/internal/routes/routes.go @@ -1,3 +1,18 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 package routes import ( diff --git a/server/featureflags/internal/routes/routes_test.go b/server/featureflags/internal/routes/routes_test.go index f0b58b849de1..24cffd751412 100644 --- a/server/featureflags/internal/routes/routes_test.go +++ b/server/featureflags/internal/routes/routes_test.go @@ -33,11 +33,11 @@ import ( func TestRegister(t *testing.T) { var ( - cfg = config.Configuration{} - authorizer = auth.NewAuthorizer(nil) - routerInst = router.NewRouter(cfg, authorizer, "") + cfg = config.Configuration{} + authorizer = auth.NewAuthorizer(nil) + routerInst = router.NewRouter(cfg, authorizer, "") featureFlagMock = mocks.NewMockFeatureFlag(t) - handlerSet = handlers.NewHandlersContainer(featureFlagMock) + handlerSet = handlers.NewHandlersContainer(featureFlagMock) ) routes.Register(&routerInst, handlerSet) @@ -63,11 +63,11 @@ func TestRegister(t *testing.T) { // wireup ever loses RequirePermissions/RequireAuth, this test will fail. func TestRegister_RoutesRequireAuthentication(t *testing.T) { var ( - cfg = config.Configuration{} - authorizer = auth.NewAuthorizer(nil) - routerInst = router.NewRouter(cfg, authorizer, "") + cfg = config.Configuration{} + authorizer = auth.NewAuthorizer(nil) + routerInst = router.NewRouter(cfg, authorizer, "") featureFlagMock = mocks.NewMockFeatureFlag(t) - handlerSet = handlers.NewHandlersContainer(featureFlagMock) + handlerSet = handlers.NewHandlersContainer(featureFlagMock) ) routes.Register(&routerInst, handlerSet) diff --git a/server/featureflags/internal/services/mocks/database.go b/server/featureflags/internal/services/mocks/database.go index d7ff75a656d9..9e621e25bf69 100644 --- a/server/featureflags/internal/services/mocks/database.go +++ b/server/featureflags/internal/services/mocks/database.go @@ -1,3 +1,18 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify diff --git a/server/featureflags/internal/services/services_test.go b/server/featureflags/internal/services/services_test.go index 7484622f4b05..45c70613b237 100644 --- a/server/featureflags/internal/services/services_test.go +++ b/server/featureflags/internal/services/services_test.go @@ -250,4 +250,3 @@ func TestService_ToggleFlag(t *testing.T) { assert.ErrorIs(t, err, setFlagErr) }) } - diff --git a/server/featureflags/mocks/featureflagrequestadapter.go b/server/featureflags/mocks/featureflagrequestadapter.go index 046b5f510593..9875c91d0050 100644 --- a/server/featureflags/mocks/featureflagrequestadapter.go +++ b/server/featureflags/mocks/featureflagrequestadapter.go @@ -1,3 +1,18 @@ +// Copyright 2026 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 // Code generated by mockery; DO NOT EDIT. // github.com/vektra/mockery // template: testify From 764c4d9a408ca60d00c88bb69f60c2bb8ef8d78b Mon Sep 17 00:00:00 2001 From: Flechas Bentley Date: Thu, 25 Jun 2026 11:58:03 -0500 Subject: [PATCH 3/7] jpfcr --- server/featureflags/internal/handlers/views.go | 2 +- server/featureflags/internal/routes/routes.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/featureflags/internal/handlers/views.go b/server/featureflags/internal/handlers/views.go index bc5fbcec00dd..69ce47d0a717 100644 --- a/server/featureflags/internal/handlers/views.go +++ b/server/featureflags/internal/handlers/views.go @@ -4,7 +4,7 @@ // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// http://www.apache.org/licenses/LICENSE-2.0 +// http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, diff --git a/server/featureflags/internal/routes/routes.go b/server/featureflags/internal/routes/routes.go index 3b023eadc001..d3114a1f4eb0 100644 --- a/server/featureflags/internal/routes/routes.go +++ b/server/featureflags/internal/routes/routes.go @@ -4,7 +4,7 @@ // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// http://www.apache.org/licenses/LICENSE-2.0 +// http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, From 80da836e36e47114efee3bac5905b32eef42518c Mon Sep 17 00:00:00 2001 From: Flechas Bentley Date: Mon, 29 Jun 2026 11:34:02 -0500 Subject: [PATCH 4/7] address pr feedback --- server/featureflags/featureflags.go | 2 -- .../internal/handlers/handlers.go | 15 +++++++-------- .../featureflags/internal/handlers/views.go | 19 +++++++++---------- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/server/featureflags/featureflags.go b/server/featureflags/featureflags.go index 889574bcf114..720ff147d88a 100644 --- a/server/featureflags/featureflags.go +++ b/server/featureflags/featureflags.go @@ -22,7 +22,6 @@ package featureflags import ( "context" - "net/http" "github.com/jackc/pgx/v5/pgxpool" "github.com/specterops/bloodhound/cmd/api/src/api/router" @@ -39,7 +38,6 @@ const ( type FeatureFlagRequestAdapter interface { IsEnabled(ctx context.Context, key string) (bool, error) - ToggleFlag(response http.ResponseWriter, request *http.Request) } func NewFeatureFlagRequestAdapter(pool *pgxpool.Pool) FeatureFlagRequestAdapter { diff --git a/server/featureflags/internal/handlers/handlers.go b/server/featureflags/internal/handlers/handlers.go index 0cc7ae72b059..575d140774de 100644 --- a/server/featureflags/internal/handlers/handlers.go +++ b/server/featureflags/internal/handlers/handlers.go @@ -41,12 +41,12 @@ type FeatureFlag interface { IsEnabled(ctx context.Context, key string) (bool, error) } -// Handlers is a dependency injection container for app_config handlers +// Handlers is a dependency injection container for featureflags handlers type Handlers struct { featureFlag FeatureFlag } -// NewHandlersContainer initializes the Handlers dependency injection container +// NewHandlersContainer initializes the featureflags Handlers dependency injection container func NewHandlersContainer(featureFlag FeatureFlag) *Handlers { return &Handlers{ featureFlag: featureFlag, @@ -61,7 +61,7 @@ func (s Handlers) GetAllFlags(response http.ResponseWriter, request *http.Reques flags, err := s.featureFlag.GetAllFlags(ctx) if err != nil { - handleAppConfigError(request, response, err) + handleFeatureFlagError(request, response, err) return } @@ -71,8 +71,7 @@ func (s Handlers) GetAllFlags(response http.ResponseWriter, request *http.Reques // ToggleFlag toggles the Enabled state of the feature flag identified by the // feature_id path parameter. The handler delegates to the service layer, which // loads the existing flag, validates that it is user-updatable, flips its -// Enabled value, persists the result, and conditionally triggers an analysis -// request when the ADCS flag is being disabled. +// Enabled value and persists the result. // // Authentication is enforced by the route middleware (RequirePermissions); if no // user is present on the auth context here it indicates an unexpected internal @@ -96,7 +95,7 @@ func (s Handlers) ToggleFlag(response http.ResponseWriter, request *http.Request flag, err := s.featureFlag.ToggleFlag(ctx, int32(featureID), user.ID.String()) if err != nil { - handleAppConfigError(request, response, err) + handleFeatureFlagError(request, response, err) return } @@ -110,10 +109,10 @@ func (s Handlers) IsEnabled(ctx context.Context, key string) (bool, error) { return s.featureFlag.IsEnabled(ctx, key) } -// handleAppConfigError maps service-layer errors to HTTP responses, translating +// handleFeatureFlagError maps service-layer errors to HTTP responses, translating // known sentinel errors to their corresponding status codes and falling back to // a logged 500 for anything unexpected. -func handleAppConfigError(request *http.Request, response http.ResponseWriter, err error) { +func handleFeatureFlagError(request *http.Request, response http.ResponseWriter, err error) { if errors.Is(err, services.ErrNotFound) { responses.WriteError(request.Context(), http.StatusNotFound, services.ErrNotFound.Error(), response) } else if errors.Is(err, services.ErrNotUserUpdatable) { diff --git a/server/featureflags/internal/handlers/views.go b/server/featureflags/internal/handlers/views.go index 69ce47d0a717..e99d09d581d4 100644 --- a/server/featureflags/internal/handlers/views.go +++ b/server/featureflags/internal/handlers/views.go @@ -16,7 +16,6 @@ package handlers import ( - "database/sql" "encoding/json" "time" @@ -24,15 +23,15 @@ import ( ) type FeatureFlagView struct { - ID int32 `json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt sql.NullTime `json:"deleted_at"` - Key string `json:"key"` - Name string `json:"name"` - Description string `json:"description"` - Enabled bool `json:"enabled"` - UserUpdatable bool `json:"user_updatable"` + ID int32 `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt time.Time `json:"deleted_at"` + Key string `json:"key"` + Name string `json:"name"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + UserUpdatable bool `json:"user_updatable"` } func BuildFeatureFlagView(tf services.FeatureFlag) FeatureFlagView { From 719e3d0daf5f7ef1ec4dc8b46619cdeaebfc66f0 Mon Sep 17 00:00:00 2001 From: Flechas Bentley Date: Mon, 29 Jun 2026 12:36:29 -0500 Subject: [PATCH 5/7] jpfcr --- .../mocks/featureflagrequestadapter.go | 47 ------------------- .../internal/appdb/node_integration_test.go | 5 +- server/modules/modules_test.go | 6 +-- 3 files changed, 6 insertions(+), 52 deletions(-) diff --git a/server/featureflags/mocks/featureflagrequestadapter.go b/server/featureflags/mocks/featureflagrequestadapter.go index 9875c91d0050..8de65d54ff90 100644 --- a/server/featureflags/mocks/featureflagrequestadapter.go +++ b/server/featureflags/mocks/featureflagrequestadapter.go @@ -21,7 +21,6 @@ package mocks import ( "context" - "net/http" mock "github.com/stretchr/testify/mock" ) @@ -118,49 +117,3 @@ func (_c *MockFeatureFlagRequestAdapter_IsEnabled_Call) RunAndReturn(run func(ct _c.Call.Return(run) return _c } - -// ToggleFlag provides a mock function for the type MockFeatureFlagRequestAdapter -func (_mock *MockFeatureFlagRequestAdapter) ToggleFlag(response http.ResponseWriter, request *http.Request) { - _mock.Called(response, request) - return -} - -// MockFeatureFlagRequestAdapter_ToggleFlag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ToggleFlag' -type MockFeatureFlagRequestAdapter_ToggleFlag_Call struct { - *mock.Call -} - -// ToggleFlag is a helper method to define mock.On call -// - response http.ResponseWriter -// - request *http.Request -func (_e *MockFeatureFlagRequestAdapter_Expecter) ToggleFlag(response interface{}, request interface{}) *MockFeatureFlagRequestAdapter_ToggleFlag_Call { - return &MockFeatureFlagRequestAdapter_ToggleFlag_Call{Call: _e.mock.On("ToggleFlag", response, request)} -} - -func (_c *MockFeatureFlagRequestAdapter_ToggleFlag_Call) Run(run func(response http.ResponseWriter, request *http.Request)) *MockFeatureFlagRequestAdapter_ToggleFlag_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 http.ResponseWriter - if args[0] != nil { - arg0 = args[0].(http.ResponseWriter) - } - var arg1 *http.Request - if args[1] != nil { - arg1 = args[1].(*http.Request) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockFeatureFlagRequestAdapter_ToggleFlag_Call) Return() *MockFeatureFlagRequestAdapter_ToggleFlag_Call { - _c.Call.Return() - return _c -} - -func (_c *MockFeatureFlagRequestAdapter_ToggleFlag_Call) RunAndReturn(run func(response http.ResponseWriter, request *http.Request)) *MockFeatureFlagRequestAdapter_ToggleFlag_Call { - _c.Run(run) - return _c -} diff --git a/server/graphdb/internal/appdb/node_integration_test.go b/server/graphdb/internal/appdb/node_integration_test.go index 2ad145e42a1d..c965a795c7e9 100644 --- a/server/graphdb/internal/appdb/node_integration_test.go +++ b/server/graphdb/internal/appdb/node_integration_test.go @@ -320,9 +320,10 @@ func TestStore_GetNodeKindsByNames_Integration(t *testing.T) { var registeredKind, unregisteredKind services.Kind for _, kind := range kinds { - if kind.Name == "TestRegisteredKindMixed" { + switch kind.Name { + case "TestRegisteredKindMixed": registeredKind = kind - } else if kind.Name == "TestUnregisteredKind" { + case "TestUnregisteredKind": unregisteredKind = kind } } diff --git a/server/modules/modules_test.go b/server/modules/modules_test.go index db7d7f25bcda..5417814079bd 100644 --- a/server/modules/modules_test.go +++ b/server/modules/modules_test.go @@ -130,9 +130,9 @@ func TestRegister_WiresFeatureModuleRoutes(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { var ( - muxRouter = routerInst.MuxRouter() - request = httptest.NewRequest(tc.method, tc.path, nil) - match mux.RouteMatch + muxRouter = routerInst.MuxRouter() + request = httptest.NewRequest(tc.method, tc.path, nil) + match mux.RouteMatch ) assert.True(t, muxRouter.Match(request, &match), "%s %s route should be registered by Register", tc.method, tc.path) From e106e51548f38f80ec4390e14e2c5e9666576ce9 Mon Sep 17 00:00:00 2001 From: Flechas Bentley Date: Tue, 30 Jun 2026 11:40:35 -0500 Subject: [PATCH 6/7] address pr feedback --- server/featureflags/internal/handlers/views.go | 1 - server/featureflags/internal/services/services.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/server/featureflags/internal/handlers/views.go b/server/featureflags/internal/handlers/views.go index e99d09d581d4..d32201e6866f 100644 --- a/server/featureflags/internal/handlers/views.go +++ b/server/featureflags/internal/handlers/views.go @@ -26,7 +26,6 @@ type FeatureFlagView struct { ID int32 `json:"id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - DeletedAt time.Time `json:"deleted_at"` Key string `json:"key"` Name string `json:"name"` Description string `json:"description"` diff --git a/server/featureflags/internal/services/services.go b/server/featureflags/internal/services/services.go index 694744229c16..e7ecee915a9d 100644 --- a/server/featureflags/internal/services/services.go +++ b/server/featureflags/internal/services/services.go @@ -107,7 +107,7 @@ func (s *Service) GetAllFlags(ctx context.Context) ([]FeatureFlag, error) { } // ToggleFlag enables/disables the feature flag by the feature flag id -func (s *Service) ToggleFlag(ctx context.Context, id int32, requestedBy string) (FeatureFlag, error) { +func (s *Service) ToggleFlag(ctx context.Context, id int32) (FeatureFlag, error) { flag, err := s.db.GetFlagByID(ctx, id) if err != nil { return flag, err From bca5c980768eca4e4324fd871378eebe0b200246 Mon Sep 17 00:00:00 2001 From: Flechas Bentley Date: Tue, 30 Jun 2026 11:54:48 -0500 Subject: [PATCH 7/7] fix compile errors --- server/featureflags/internal/handlers/handlers.go | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/server/featureflags/internal/handlers/handlers.go b/server/featureflags/internal/handlers/handlers.go index 575d140774de..9290cb5a7293 100644 --- a/server/featureflags/internal/handlers/handlers.go +++ b/server/featureflags/internal/handlers/handlers.go @@ -27,8 +27,6 @@ import ( "github.com/gorilla/mux" "github.com/specterops/bloodhound/cmd/api/src/api" - "github.com/specterops/bloodhound/cmd/api/src/auth" - "github.com/specterops/bloodhound/cmd/api/src/bhctx" "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/responses" "github.com/specterops/bloodhound/server/featureflags/internal/services" @@ -37,7 +35,7 @@ import ( // FeatureFlag defines the feature flag service boundary for the feature flag handlers package. type FeatureFlag interface { GetAllFlags(ctx context.Context) ([]services.FeatureFlag, error) - ToggleFlag(ctx context.Context, id int32, requestedBy string) (services.FeatureFlag, error) + ToggleFlag(ctx context.Context, id int32) (services.FeatureFlag, error) IsEnabled(ctx context.Context, key string) (bool, error) } @@ -79,12 +77,6 @@ func (s Handlers) GetAllFlags(response http.ResponseWriter, request *http.Reques func (s Handlers) ToggleFlag(response http.ResponseWriter, request *http.Request) { var ctx = request.Context() - user, isUser := auth.GetUserFromAuthCtx(bhctx.FromRequest(request).AuthCtx) - if !isUser { - responses.WriteInternalServerError(ctx, errors.New("no user on auth context after authentication middleware"), response) - return - } - rawFeatureID := mux.Vars(request)[api.URIPathVariableFeatureID] featureID, err := strconv.ParseInt(rawFeatureID, 10, 32) @@ -93,7 +85,7 @@ func (s Handlers) ToggleFlag(response http.ResponseWriter, request *http.Request return } - flag, err := s.featureFlag.ToggleFlag(ctx, int32(featureID), user.ID.String()) + flag, err := s.featureFlag.ToggleFlag(ctx, int32(featureID)) if err != nil { handleFeatureFlagError(request, response, err) return