From 5a3c6dd7d9d4074c14fc1e70f0438026e60205dc Mon Sep 17 00:00:00 2001 From: Stephanie Lamb Date: Thu, 25 Jun 2026 10:15:18 -0500 Subject: [PATCH 1/5] initial onion set up. added appdb layer --- server/identity/identity.go | 1 + server/identity/identity_e2e_test.go | 285 ++++++++++++++++++ server/identity/internal/appdb/appdb.go | 77 +++++ .../internal/appdb/appdb_integration_test.go | 146 +++++++++ server/identity/internal/appdb/appdb_test.go | 133 ++++++++ server/identity/internal/services/services.go | 19 ++ 6 files changed, 661 insertions(+) create mode 100644 server/identity/identity.go create mode 100644 server/identity/identity_e2e_test.go create mode 100644 server/identity/internal/appdb/appdb.go create mode 100644 server/identity/internal/appdb/appdb_integration_test.go create mode 100644 server/identity/internal/appdb/appdb_test.go create mode 100644 server/identity/internal/services/services.go diff --git a/server/identity/identity.go b/server/identity/identity.go new file mode 100644 index 00000000000..cca56a06aef --- /dev/null +++ b/server/identity/identity.go @@ -0,0 +1 @@ +package identity diff --git a/server/identity/identity_e2e_test.go b/server/identity/identity_e2e_test.go new file mode 100644 index 00000000000..00097989a40 --- /dev/null +++ b/server/identity/identity_e2e_test.go @@ -0,0 +1,285 @@ +// 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 identity_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/peterldowns/pgtestdb" + authapi "github.com/specterops/bloodhound/cmd/api/src/api/v2/auth" + "github.com/specterops/bloodhound/cmd/api/src/auth" + "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/services/dogtags" + "github.com/specterops/bloodhound/cmd/api/src/test/integration/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupIdentityDB creates an isolated test database with all migrations applied. +// The database is automatically closed when the test ends. +func setupIdentityDB(t *testing.T) *database.BloodhoundDB { + t.Helper() + + var ( + ctx = context.Background() + connConf = pgtestdb.Custom(t, getIdentityPostgresConfig(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 +} + +// getIdentityPostgresConfig reads the integration test configuration from the +// environment and returns a pgtestdb.Config for the identity e2e tests. +func getIdentityPostgresConfig(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, + } +} + +// createTestUser inserts a minimal user into the database for use in tests. +func createTestUser(t *testing.T, ctx context.Context, db *database.BloodhoundDB, principalName string) model.User { + t.Helper() + user, err := db.CreateUser(ctx, model.User{ + PrincipalName: principalName, + EULAAccepted: true, + AllEnvironments: true, + }) + require.NoError(t, err) + return user +} + +// newManagementResource wires a real ManagementResource backed by the given database. +func newManagementResource(t *testing.T, db *database.BloodhoundDB) authapi.ManagementResource { + t.Helper() + cfg, err := config.NewDefaultConfiguration() + require.NoError(t, err) + return authapi.NewManagementResource(cfg, db, auth.NewAuthorizer(db), nil, nil, dogtags.NewDefaultService()) +} + +// permissionResponseEnvelope is the JSON envelope shape returned by the +// GET /api/v2/permissions/{permission_id} handler. +type permissionResponseEnvelope struct { + Data model.Permission `json:"data"` +} + +// roleResponseEnvelope is the JSON envelope shape returned by the +// GET /api/v2/roles/{role_id} handler. +type roleResponseEnvelope struct { + Data model.Role `json:"data"` +} + +// userResponseEnvelope is the JSON envelope shape returned by the +// GET /api/v2/bloodhound-users/{user_id} handler. +type userResponseEnvelope struct { + Data model.User `json:"data"` +} + +func TestGetPermission(t *testing.T) { + var ( + db = setupIdentityDB(t) + ctx = context.Background() + resource = newManagementResource(t, db) + handler = resource.GetPermission + permissions model.Permissions + err error + ) + + permissions, err = db.GetAllPermissions(ctx, "", model.SQLFilter{}) + require.NoError(t, err) + require.NotEmpty(t, permissions, "expected migrations to seed at least one permission") + seededPermission := permissions[0] + + newRequest := func(t *testing.T, permissionID string) *http.Request { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/api/v2/permissions/"+permissionID, nil) + require.NoError(t, err) + return mux.SetURLVars(req, map[string]string{"permission_id": permissionID}) + } + + t.Run("returns 200 OK with the permission for a valid ID", func(t *testing.T) { + recorder := httptest.NewRecorder() + handler(recorder, newRequest(t, fmt.Sprintf("%d", seededPermission.ID))) + + assert.Equal(t, http.StatusOK, recorder.Code) + + var envelope permissionResponseEnvelope + require.NoError(t, json.NewDecoder(recorder.Body).Decode(&envelope)) + assert.Equal(t, seededPermission.ID, envelope.Data.ID) + assert.Equal(t, seededPermission.Authority, envelope.Data.Authority) + assert.Equal(t, seededPermission.Name, envelope.Data.Name) + }) + + t.Run("returns 404 Not Found when the permission does not exist", func(t *testing.T) { + recorder := httptest.NewRecorder() + handler(recorder, newRequest(t, "99999999")) + assert.Equal(t, http.StatusNotFound, recorder.Code) + }) + + t.Run("returns 400 Bad Request for a malformed permission ID", func(t *testing.T) { + recorder := httptest.NewRecorder() + handler(recorder, newRequest(t, "not-an-int")) + assert.Equal(t, http.StatusBadRequest, recorder.Code) + }) +} + +func TestGetRole(t *testing.T) { + var ( + db = setupIdentityDB(t) + ctx = context.Background() + resource = newManagementResource(t, db) + handler = resource.GetRole + roles model.Roles + err error + ) + + roles, err = db.GetAllRoles(ctx, "", model.SQLFilter{}) + require.NoError(t, err) + require.NotEmpty(t, roles, "expected migrations to seed at least one role") + seededRole := roles[0] + + newRequest := func(t *testing.T, roleID string) *http.Request { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/api/v2/roles/"+roleID, nil) + require.NoError(t, err) + return mux.SetURLVars(req, map[string]string{"role_id": roleID}) + } + + t.Run("returns 200 OK with the role for a valid ID", func(t *testing.T) { + recorder := httptest.NewRecorder() + handler(recorder, newRequest(t, fmt.Sprintf("%d", seededRole.ID))) + + assert.Equal(t, http.StatusOK, recorder.Code) + + var envelope roleResponseEnvelope + require.NoError(t, json.NewDecoder(recorder.Body).Decode(&envelope)) + assert.Equal(t, seededRole.ID, envelope.Data.ID) + assert.Equal(t, seededRole.Name, envelope.Data.Name) + assert.NotEmpty(t, envelope.Data.Permissions, "expected the role to preload its permissions") + }) + + t.Run("returns 404 Not Found when the role does not exist", func(t *testing.T) { + recorder := httptest.NewRecorder() + handler(recorder, newRequest(t, "99999999")) + assert.Equal(t, http.StatusNotFound, recorder.Code) + }) + + t.Run("returns 400 Bad Request for a malformed role ID", func(t *testing.T) { + recorder := httptest.NewRecorder() + handler(recorder, newRequest(t, "not-an-int")) + assert.Equal(t, http.StatusBadRequest, recorder.Code) + }) +} + +func TestGetUser(t *testing.T) { + var ( + db = setupIdentityDB(t) + ctx = context.Background() + user = createTestUser(t, ctx, db, "get-user-principal") + resource = newManagementResource(t, db) + handler = resource.GetUser + ) + + newRequest := func(t *testing.T, userID string) *http.Request { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/api/v2/bloodhound-users/"+userID, nil) + require.NoError(t, err) + return mux.SetURLVars(req, map[string]string{"user_id": userID}) + } + + t.Run("returns 200 OK with the user for a valid ID", func(t *testing.T) { + recorder := httptest.NewRecorder() + handler(recorder, newRequest(t, user.ID.String())) + + assert.Equal(t, http.StatusOK, recorder.Code) + + var envelope userResponseEnvelope + require.NoError(t, json.NewDecoder(recorder.Body).Decode(&envelope)) + assert.Equal(t, user.ID, envelope.Data.ID) + assert.Equal(t, user.PrincipalName, envelope.Data.PrincipalName) + }) + + t.Run("returns 404 Not Found when the user does not exist", func(t *testing.T) { + nonExistentID := uuid.Must(uuid.NewV4()) + recorder := httptest.NewRecorder() + handler(recorder, newRequest(t, nonExistentID.String())) + assert.Equal(t, http.StatusNotFound, recorder.Code) + }) + + t.Run("returns 400 Bad Request for a malformed user ID", func(t *testing.T) { + recorder := httptest.NewRecorder() + handler(recorder, newRequest(t, "not-a-uuid")) + assert.Equal(t, http.StatusBadRequest, recorder.Code) + }) +} diff --git a/server/identity/internal/appdb/appdb.go b/server/identity/internal/appdb/appdb.go new file mode 100644 index 00000000000..ff2fe2742ee --- /dev/null +++ b/server/identity/internal/appdb/appdb.go @@ -0,0 +1,77 @@ +package appdb + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/huandu/go-sqlbuilder" + "github.com/jackc/pgx/v5" + "github.com/specterops/bloodhound/server/identity/internal/services" +) + +const ( + tablePermissions = "permissions" +) + +type queryExecer interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) +} + +type pgxQuerier interface { + queryExecer +} + +type permission struct { + Authority string `db:"authority"` + Name string `db:"name"` + ID int32 `db:"id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + DeletedAt sql.NullTime `db:"deleted_at"` +} + +type Store struct { + db pgxQuerier +} + +func NewStore(db pgxQuerier) *Store { + return &Store{db: db} +} + +func toPermission(row permission) services.Permission { + return services.Permission{ + Authority: row.Authority, + Name: row.Name, + ID: row.ID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + DeletedAt: row.DeletedAt, + } +} + +func (s *Store) GetPermission(ctx context.Context, id int) (services.Permission, error) { + sb := sqlbuilder.PostgreSQL.NewSelectBuilder() + sb.Select("*") + sb.From(tablePermissions) + sb.Where(sb.Equal("id", id)) + sb.Limit(1) + + query, args := sb.Build() + + rows, err := s.db.Query(ctx, query, args...) + if err != nil { + return services.Permission{}, err + } + row, err := pgx.CollectOneRow(rows, pgx.RowToStructByNameLax[permission]) + if errors.Is(err, pgx.ErrNoRows) { + return services.Permission{}, services.ErrNoPermissionFound + } + if err != nil { + return services.Permission{}, fmt.Errorf("finding permission: %s", err) + } + + return toPermission(row), nil +} diff --git a/server/identity/internal/appdb/appdb_integration_test.go b/server/identity/internal/appdb/appdb_integration_test.go new file mode 100644 index 00000000000..4c20bf2ac5b --- /dev/null +++ b/server/identity/internal/appdb/appdb_integration_test.go @@ -0,0 +1,146 @@ +// 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 appdb_test + +import ( + "context" + "fmt" + "net/url" + "strings" + "testing" + + "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/config" + "github.com/specterops/bloodhound/cmd/api/src/database" + "github.com/specterops/bloodhound/cmd/api/src/test/integration/utils" + "github.com/specterops/bloodhound/server/identity/internal/appdb" + "github.com/specterops/bloodhound/server/identity/internal/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupStoreAndPool spins up an isolated postgres database via pgtestdb, applies all +// migrations, and returns an identity Store backed by the resulting pgx pool along +// with the pool itself so callers can read seeded rows directly. +func setupStoreAndPool(t *testing.T) (*appdb.Store, *pgxpool.Pool) { + t.Helper() + + var ( + ctx = context.Background() + connConf = pgtestdb.Custom(t, getPostgresConfig(t), pgtestdb.NoopMigrator{}) + ) + + cfg, err := config.NewDefaultConnectionConfiguration(connConf.URL()) + require.NoError(t, err) + + gormDB, dbPool, err := database.OpenDatabase(cfg.Database) + require.NoError(t, err) + + bhDB := database.NewBloodhoundDB(gormDB, dbPool, auth.NewIdentityResolver(), cfg) + require.NoError(t, bhDB.Migrate(ctx)) + + t.Cleanup(func() { bhDB.Close(ctx) }) + + return appdb.NewStore(bhDB.Pool()), bhDB.Pool() +} + +// getPostgresConfig reads the integration test connection details from the +// configured environment and returns a pgtestdb.Config suitable for spinning +// up isolated databases. Supports both TCP and unix-socket host values. +func getPostgresConfig(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, + } +} + +// seededPermission reads a single permission seeded by the migrations directly +// from the pool so tests have a known-good id to look up via the Store. +func seededPermission(t *testing.T, ctx context.Context, pool *pgxpool.Pool) services.Permission { + t.Helper() + + var found services.Permission + err := pool.QueryRow(ctx, "SELECT id, authority, name FROM permissions ORDER BY id LIMIT 1"). + Scan(&found.ID, &found.Authority, &found.Name) + require.NoError(t, err) + + return found +} + +func TestStore_GetPermission_Integration(t *testing.T) { + t.Run("returns the permission for a seeded id", func(t *testing.T) { + var ( + ctx = context.Background() + store, pool = setupStoreAndPool(t) + ) + + expected := seededPermission(t, ctx, pool) + + retrieved, err := store.GetPermission(ctx, int(expected.ID)) + require.NoError(t, err) + assert.Equal(t, expected.ID, retrieved.ID) + assert.Equal(t, expected.Authority, retrieved.Authority) + assert.Equal(t, expected.Name, retrieved.Name) + }) + + t.Run("returns ErrNoPermissionFound when the permission does not exist", func(t *testing.T) { + var ( + ctx = context.Background() + store, _ = setupStoreAndPool(t) + ) + + _, err := store.GetPermission(ctx, 99999999) + assert.ErrorIs(t, err, services.ErrNoPermissionFound) + }) +} diff --git a/server/identity/internal/appdb/appdb_test.go b/server/identity/internal/appdb/appdb_test.go new file mode 100644 index 00000000000..3f4c1199aa4 --- /dev/null +++ b/server/identity/internal/appdb/appdb_test.go @@ -0,0 +1,133 @@ +// 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 appdb_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/pashagolub/pgxmock/v4" + "github.com/specterops/bloodhound/server/identity/internal/appdb" + "github.com/specterops/bloodhound/server/identity/internal/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// expectedGetPermissionSQL is the literal SQL the Store issues for getPermission. +// It is compared via pgxmock.QueryMatcherEqual, which whitespace-normalises both +// sides, so token order, table name and parameter shape are what matter. +const expectedGetPermissionSQL = `SELECT * FROM permissions WHERE id = $1 LIMIT $2` + +func newTestStore(t *testing.T) (*appdb.Store, pgxmock.PgxPoolIface) { + t.Helper() + pool, err := pgxmock.NewPool(pgxmock.QueryMatcherOption(pgxmock.QueryMatcherEqual)) + require.NoError(t, err) + t.Cleanup(pool.Close) + return appdb.NewStore(pool), pool +} + +func permissionRowColumns() []string { + return []string{"authority", "name", "id", "created_at", "updated_at"} +} + +func TestStore_GetPermission(t *testing.T) { + var ( + ctx = context.Background() + dbErr = errors.New("connection refused") + permissionID = 5 + expected = services.Permission{ + Authority: "clients", + Name: "ReadClients", + ID: 5, + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), + } + ) + + tests := []struct { + name string + expectations func(pool pgxmock.PgxPoolIface) + wantResult services.Permission + wantErr error + wantErrContains string + }{ + { + name: "returns the permission on success", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedGetPermissionSQL).WithArgs(permissionID, 1).WillReturnRows( + pool.NewRows(permissionRowColumns()).AddRow( + expected.Authority, + expected.Name, + expected.ID, + expected.CreatedAt, + expected.UpdatedAt, + ), + ) + }, + wantResult: expected, + }, + { + name: "maps CollectOneRow pgx.ErrNoRows to services.ErrNoPermissionFound", + expectations: func(pool pgxmock.PgxPoolIface) { + // Query succeeds but returns zero rows; CollectOneRow returns pgx.ErrNoRows + pool.ExpectQuery(expectedGetPermissionSQL).WithArgs(permissionID, 1).WillReturnRows( + pool.NewRows(permissionRowColumns()), + ) + }, + wantErr: services.ErrNoPermissionFound, + }, + { + name: "wraps CollectOneRow iteration error", + expectations: func(pool pgxmock.PgxPoolIface) { + // The rows object carries a close error that pgx.CollectOneRow surfaces + // via rows.Err() when Next() returns false. + pool.ExpectQuery(expectedGetPermissionSQL).WithArgs(permissionID, 1).WillReturnRows( + pool.NewRows(permissionRowColumns()).CloseError(errors.New("forced iteration error")), + ) + }, + wantErrContains: "finding permission:", + }, + { + name: "propagates other database errors", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedGetPermissionSQL).WithArgs(permissionID, 1).WillReturnError(dbErr) + }, + wantErr: dbErr, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, pool := newTestStore(t) + tt.expectations(pool) + + result, err := store.GetPermission(ctx, permissionID) + switch { + case tt.wantErr != nil: + assert.ErrorIs(t, err, tt.wantErr) + case tt.wantErrContains != "": + assert.ErrorContains(t, err, tt.wantErrContains) + default: + require.NoError(t, err) + assert.Equal(t, tt.wantResult, result) + } + require.NoError(t, pool.ExpectationsWereMet()) + }) + } +} diff --git a/server/identity/internal/services/services.go b/server/identity/internal/services/services.go new file mode 100644 index 00000000000..354dc149e46 --- /dev/null +++ b/server/identity/internal/services/services.go @@ -0,0 +1,19 @@ +package services + +import ( + "database/sql" + "errors" + "time" +) + +type Permission struct { + Authority string `json:"authority"` + Name string `json:"name"` + ID int32 `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +// ErrNoPermissionFound indicates that there is no analysis request currently pending. +var ErrNoPermissionFound = errors.New("no permission was found") From 08f108fc9898476d1dd0076d79a513c1ebacfc3a Mon Sep 17 00:00:00 2001 From: Stephanie Lamb Date: Fri, 26 Jun 2026 13:13:47 -0500 Subject: [PATCH 2/5] added additional functions to appdb. built out services.go --- .../internal/handlers/mocks/analysis.go | 15 -- .../internal/services/mocks/database.go | 15 -- .../analysis/mocks/analysisrequestadapter.go | 15 -- .../internal/services/mocks/database.go | 15 -- server/featureflags/mocks/service.go | 15 -- server/identity/internal/appdb/appdb.go | 142 ++++++++++- .../internal/appdb/appdb_integration_test.go | 86 +++++++ server/identity/internal/appdb/appdb_test.go | 203 ++++++++++++++- .../internal/services/mocks/database.go | 238 ++++++++++++++++++ server/identity/internal/services/services.go | 75 +++++- .../internal/services/services_test.go | 148 +++++++++++ 11 files changed, 878 insertions(+), 89 deletions(-) create mode 100644 server/identity/internal/services/mocks/database.go create mode 100644 server/identity/internal/services/services_test.go diff --git a/server/analysis/internal/handlers/mocks/analysis.go b/server/analysis/internal/handlers/mocks/analysis.go index 1fb8e1a2718..735bce272e1 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 b52b9f8cc10..e87b72e7dc8 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 f154dbf182b..6bdad1ceb35 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/internal/services/mocks/database.go b/server/featureflags/internal/services/mocks/database.go index 3a655dfc46e..24045c1455a 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 diff --git a/server/featureflags/mocks/service.go b/server/featureflags/mocks/service.go index b73d9f9f8ff..1d3fee9b358 100644 --- a/server/featureflags/mocks/service.go +++ b/server/featureflags/mocks/service.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/identity/internal/appdb/appdb.go b/server/identity/internal/appdb/appdb.go index ff2fe2742ee..dc58c738f5e 100644 --- a/server/identity/internal/appdb/appdb.go +++ b/server/identity/internal/appdb/appdb.go @@ -2,18 +2,21 @@ package appdb import ( "context" - "database/sql" "errors" "fmt" "time" + "github.com/gofrs/uuid" "github.com/huandu/go-sqlbuilder" "github.com/jackc/pgx/v5" "github.com/specterops/bloodhound/server/identity/internal/services" ) const ( - tablePermissions = "permissions" + tablePermissions = "permissions" + tableRoles = "roles" + tableRolesPermissions = "roles_permissions" + tableUsers = "users" ) type queryExecer interface { @@ -25,12 +28,28 @@ type pgxQuerier interface { } type permission struct { - Authority string `db:"authority"` - Name string `db:"name"` - ID int32 `db:"id"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - DeletedAt sql.NullTime `db:"deleted_at"` + Authority string `db:"authority"` + Name string `db:"name"` + ID int32 `db:"id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type role struct { + ID int32 `db:"id"` + Name string `db:"name"` + Description string `db:"description"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type user struct { + ID string `db:"id"` + PrincipalName string `db:"principal_name"` + IsDisabled bool `db:"is_disabled"` + EULAAccepted bool `db:"eula_accepted"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } type Store struct { @@ -48,10 +67,113 @@ func toPermission(row permission) services.Permission { ID: row.ID, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, - DeletedAt: row.DeletedAt, } } +func toRole(row role, permissionRows []permission) services.Role { + var permissions []services.Permission + for _, permissionRow := range permissionRows { + permissions = append(permissions, toPermission(permissionRow)) + } + return services.Role{ + ID: row.ID, + Name: row.Name, + Description: row.Description, + Permissions: permissions, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func toUser(row user) (services.User, error) { + var ( + userID uuid.UUID + err error + ) + userID, err = uuid.FromString(row.ID) + if err != nil { + return services.User{}, fmt.Errorf("parsing user id: %w", err) + } + return services.User{ + ID: userID, + PrincipalName: row.PrincipalName, + IsDisabled: row.IsDisabled, + EULAAccepted: row.EULAAccepted, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }, nil +} + +func (s *Store) GetRole(ctx context.Context, id int32) (services.Role, error) { + var ( + roleSB = sqlbuilder.PostgreSQL.NewSelectBuilder() + roleRows pgx.Rows + roleRow role + err error + ) + + roleSB.Select("*").From(tableRoles).Where(roleSB.Equal("id", id)).Limit(1) + roleQuery, roleArgs := roleSB.Build() + + roleRows, err = s.db.Query(ctx, roleQuery, roleArgs...) + if err != nil { + return services.Role{}, err + } + roleRow, err = pgx.CollectOneRow(roleRows, pgx.RowToStructByName[role]) + if errors.Is(err, pgx.ErrNoRows) { + return services.Role{}, services.ErrNoRoleFound + } + if err != nil { + return services.Role{}, fmt.Errorf("finding role: %s", err) + } + + // Fetch permissions associated with this role via the join table. + permQuery := fmt.Sprintf( + "SELECT p.id, p.authority, p.name, p.created_at, p.updated_at FROM %s p JOIN %s rp ON rp.permission_id = p.id WHERE rp.role_id = $1", + tablePermissions, + tableRolesPermissions, + ) + permRows, permErr := s.db.Query(ctx, permQuery, id) + if permErr != nil { + return services.Role{}, fmt.Errorf("querying permissions for role: %s", permErr) + } + permissionRows, permErr := pgx.CollectRows(permRows, pgx.RowToStructByName[permission]) + if permErr != nil { + return services.Role{}, fmt.Errorf("collecting permissions for role: %s", permErr) + } + + return toRole(roleRow, permissionRows), nil +} + +func (s *Store) GetUser(ctx context.Context, id uuid.UUID) (services.User, error) { + var ( + userSB = sqlbuilder.PostgreSQL.NewSelectBuilder() + userRows pgx.Rows + userRow user + err error + ) + + userSB.Select("id", "principal_name", "is_disabled", "eula_accepted", "created_at", "updated_at"). + From(tableUsers). + Where(userSB.Equal("id", id.String())). + Limit(1) + userQuery, userArgs := userSB.Build() + + userRows, err = s.db.Query(ctx, userQuery, userArgs...) + if err != nil { + return services.User{}, err + } + userRow, err = pgx.CollectOneRow(userRows, pgx.RowToStructByName[user]) + if errors.Is(err, pgx.ErrNoRows) { + return services.User{}, services.ErrNoUserFound + } + if err != nil { + return services.User{}, fmt.Errorf("finding user: %s", err) + } + + return toUser(userRow) +} + func (s *Store) GetPermission(ctx context.Context, id int) (services.Permission, error) { sb := sqlbuilder.PostgreSQL.NewSelectBuilder() sb.Select("*") @@ -65,7 +187,7 @@ func (s *Store) GetPermission(ctx context.Context, id int) (services.Permission, if err != nil { return services.Permission{}, err } - row, err := pgx.CollectOneRow(rows, pgx.RowToStructByNameLax[permission]) + row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[permission]) if errors.Is(err, pgx.ErrNoRows) { return services.Permission{}, services.ErrNoPermissionFound } diff --git a/server/identity/internal/appdb/appdb_integration_test.go b/server/identity/internal/appdb/appdb_integration_test.go index 4c20bf2ac5b..83d79d3a405 100644 --- a/server/identity/internal/appdb/appdb_integration_test.go +++ b/server/identity/internal/appdb/appdb_integration_test.go @@ -25,6 +25,7 @@ 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" @@ -118,6 +119,36 @@ func seededPermission(t *testing.T, ctx context.Context, pool *pgxpool.Pool) ser return found } +// seededRole reads a single role seeded by the migrations directly from the pool. +func seededRole(t *testing.T, ctx context.Context, pool *pgxpool.Pool) services.Role { + t.Helper() + + var found services.Role + err := pool.QueryRow(ctx, "SELECT id, name FROM roles ORDER BY id LIMIT 1"). + Scan(&found.ID, &found.Name) + require.NoError(t, err) + + return found +} + +// insertTestUser inserts a minimal user row and returns its UUID. +func insertTestUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, principalName string) uuid.UUID { + t.Helper() + + var userID uuid.UUID + userID, err := uuid.NewV4() + require.NoError(t, err) + + _, err = pool.Exec(ctx, + "INSERT INTO users (id, principal_name, is_disabled, eula_accepted, created_at, updated_at) VALUES ($1, $2, false, false, NOW(), NOW())", + userID.String(), + principalName, + ) + require.NoError(t, err) + + return userID +} + func TestStore_GetPermission_Integration(t *testing.T) { t.Run("returns the permission for a seeded id", func(t *testing.T) { var ( @@ -144,3 +175,58 @@ func TestStore_GetPermission_Integration(t *testing.T) { assert.ErrorIs(t, err, services.ErrNoPermissionFound) }) } + +func TestStore_GetRole_Integration(t *testing.T) { + t.Run("returns the role with its permissions for a seeded id", func(t *testing.T) { + var ( + ctx = context.Background() + store, pool = setupStoreAndPool(t) + ) + + expected := seededRole(t, ctx, pool) + + retrieved, err := store.GetRole(ctx, expected.ID) + require.NoError(t, err) + assert.Equal(t, expected.ID, retrieved.ID) + assert.Equal(t, expected.Name, retrieved.Name) + assert.NotEmpty(t, retrieved.Permissions, "expected the seeded role to have at least one permission") + }) + + t.Run("returns ErrNoRoleFound when the role does not exist", func(t *testing.T) { + var ( + ctx = context.Background() + store, _ = setupStoreAndPool(t) + ) + + _, err := store.GetRole(ctx, 99999999) + assert.ErrorIs(t, err, services.ErrNoRoleFound) + }) +} + +func TestStore_GetUser_Integration(t *testing.T) { + t.Run("returns the user for a valid id", func(t *testing.T) { + var ( + ctx = context.Background() + store, pool = setupStoreAndPool(t) + principalName = "integration-test-user@example.com" + ) + + userID := insertTestUser(t, ctx, pool, principalName) + + retrieved, err := store.GetUser(ctx, userID) + require.NoError(t, err) + assert.Equal(t, userID, retrieved.ID) + assert.Equal(t, principalName, retrieved.PrincipalName) + }) + + t.Run("returns ErrNoUserFound when the user does not exist", func(t *testing.T) { + var ( + ctx = context.Background() + store, _ = setupStoreAndPool(t) + ) + + nonExistentID := uuid.Must(uuid.NewV4()) + _, err := store.GetUser(ctx, nonExistentID) + assert.ErrorIs(t, err, services.ErrNoUserFound) + }) +} diff --git a/server/identity/internal/appdb/appdb_test.go b/server/identity/internal/appdb/appdb_test.go index 3f4c1199aa4..c5180fa621e 100644 --- a/server/identity/internal/appdb/appdb_test.go +++ b/server/identity/internal/appdb/appdb_test.go @@ -22,6 +22,7 @@ import ( "testing" "time" + "github.com/gofrs/uuid" "github.com/pashagolub/pgxmock/v4" "github.com/specterops/bloodhound/server/identity/internal/appdb" "github.com/specterops/bloodhound/server/identity/internal/services" @@ -29,11 +30,19 @@ import ( "github.com/stretchr/testify/require" ) -// expectedGetPermissionSQL is the literal SQL the Store issues for getPermission. -// It is compared via pgxmock.QueryMatcherEqual, which whitespace-normalises both -// sides, so token order, table name and parameter shape are what matter. +// expectedGetPermissionSQL is the literal SQL the Store issues for GetPermission. const expectedGetPermissionSQL = `SELECT * FROM permissions WHERE id = $1 LIMIT $2` +// expectedGetRoleSQL is the literal SQL the Store issues for the roles query in GetRole. +const expectedGetRoleSQL = `SELECT * FROM roles WHERE id = $1 LIMIT $2` + +// expectedGetRolePermissionsSQL is the literal SQL the Store issues for the permissions +// query in GetRole. +const expectedGetRolePermissionsSQL = `SELECT p.id, p.authority, p.name, p.created_at, p.updated_at FROM permissions p JOIN roles_permissions rp ON rp.permission_id = p.id WHERE rp.role_id = $1` + +// expectedGetUserSQL is the literal SQL the Store issues for GetUser. +const expectedGetUserSQL = `SELECT id, principal_name, is_disabled, eula_accepted, created_at, updated_at FROM users WHERE id = $1 LIMIT $2` + func newTestStore(t *testing.T) (*appdb.Store, pgxmock.PgxPoolIface) { t.Helper() pool, err := pgxmock.NewPool(pgxmock.QueryMatcherOption(pgxmock.QueryMatcherEqual)) @@ -131,3 +140,191 @@ func TestStore_GetPermission(t *testing.T) { }) } } + +func roleRowColumns() []string { + return []string{"id", "name", "description", "created_at", "updated_at"} +} + +func rolePermissionRowColumns() []string { + return []string{"id", "authority", "name", "created_at", "updated_at"} +} + +func TestStore_GetRole(t *testing.T) { + var ( + ctx = context.Background() + dbErr = errors.New("connection refused") + roleID = int32(3) + createdAt = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC) + expected = services.Role{ + ID: 3, + Name: "Administrator", + Description: "Can manage the application", + Permissions: []services.Permission{ + {ID: 1, Authority: "auth", Name: "ManageProviders", CreatedAt: createdAt, UpdatedAt: updatedAt}, + }, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } + ) + + tests := []struct { + name string + expectations func(pool pgxmock.PgxPoolIface) + wantResult services.Role + wantErr error + wantErrContains string + }{ + { + name: "returns the role with permissions on success", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedGetRoleSQL).WithArgs(roleID, 1).WillReturnRows( + pool.NewRows(roleRowColumns()).AddRow( + expected.ID, + expected.Name, + expected.Description, + expected.CreatedAt, + expected.UpdatedAt, + ), + ) + pool.ExpectQuery(expectedGetRolePermissionsSQL).WithArgs(roleID).WillReturnRows( + pool.NewRows(rolePermissionRowColumns()).AddRow( + expected.Permissions[0].ID, + expected.Permissions[0].Authority, + expected.Permissions[0].Name, + expected.Permissions[0].CreatedAt, + expected.Permissions[0].UpdatedAt, + ), + ) + }, + wantResult: expected, + }, + { + name: "maps CollectOneRow pgx.ErrNoRows to services.ErrNoRoleFound", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedGetRoleSQL).WithArgs(roleID, 1).WillReturnRows( + pool.NewRows(roleRowColumns()), + ) + }, + wantErr: services.ErrNoRoleFound, + }, + { + name: "propagates role query database error", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedGetRoleSQL).WithArgs(roleID, 1).WillReturnError(dbErr) + }, + wantErr: dbErr, + }, + { + name: "wraps permissions query error", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedGetRoleSQL).WithArgs(roleID, 1).WillReturnRows( + pool.NewRows(roleRowColumns()).AddRow( + expected.ID, expected.Name, expected.Description, expected.CreatedAt, expected.UpdatedAt, + ), + ) + pool.ExpectQuery(expectedGetRolePermissionsSQL).WithArgs(roleID).WillReturnError(dbErr) + }, + wantErrContains: "querying permissions for role:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, pool := newTestStore(t) + tt.expectations(pool) + + result, err := store.GetRole(ctx, roleID) + switch { + case tt.wantErr != nil: + assert.ErrorIs(t, err, tt.wantErr) + case tt.wantErrContains != "": + assert.ErrorContains(t, err, tt.wantErrContains) + default: + require.NoError(t, err) + assert.Equal(t, tt.wantResult, result) + } + require.NoError(t, pool.ExpectationsWereMet()) + }) + } +} + +func userRowColumns() []string { + return []string{"id", "principal_name", "is_disabled", "eula_accepted", "created_at", "updated_at"} +} + +func TestStore_GetUser(t *testing.T) { + var ( + ctx = context.Background() + dbErr = errors.New("connection refused") + userID = uuid.Must(uuid.NewV4()) + expected = services.User{ + ID: userID, + PrincipalName: "testuser@example.com", + IsDisabled: false, + EULAAccepted: true, + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), + } + ) + + tests := []struct { + name string + expectations func(pool pgxmock.PgxPoolIface) + wantResult services.User + wantErr error + wantErrContains string + }{ + { + name: "returns the user on success", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedGetUserSQL).WithArgs(userID.String(), 1).WillReturnRows( + pool.NewRows(userRowColumns()).AddRow( + expected.ID.String(), + expected.PrincipalName, + expected.IsDisabled, + expected.EULAAccepted, + expected.CreatedAt, + expected.UpdatedAt, + ), + ) + }, + wantResult: expected, + }, + { + name: "maps CollectOneRow pgx.ErrNoRows to services.ErrNoUserFound", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedGetUserSQL).WithArgs(userID.String(), 1).WillReturnRows( + pool.NewRows(userRowColumns()), + ) + }, + wantErr: services.ErrNoUserFound, + }, + { + name: "propagates database errors", + expectations: func(pool pgxmock.PgxPoolIface) { + pool.ExpectQuery(expectedGetUserSQL).WithArgs(userID.String(), 1).WillReturnError(dbErr) + }, + wantErr: dbErr, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store, pool := newTestStore(t) + tt.expectations(pool) + + result, err := store.GetUser(ctx, userID) + switch { + case tt.wantErr != nil: + assert.ErrorIs(t, err, tt.wantErr) + case tt.wantErrContains != "": + assert.ErrorContains(t, err, tt.wantErrContains) + default: + require.NoError(t, err) + assert.Equal(t, tt.wantResult, result) + } + require.NoError(t, pool.ExpectationsWereMet()) + }) + } +} diff --git a/server/identity/internal/services/mocks/database.go b/server/identity/internal/services/mocks/database.go new file mode 100644 index 00000000000..2531c9e98a5 --- /dev/null +++ b/server/identity/internal/services/mocks/database.go @@ -0,0 +1,238 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/gofrs/uuid" + "github.com/specterops/bloodhound/server/identity/internal/services" + mock "github.com/stretchr/testify/mock" +) + +// NewMockDatabase creates a new instance of MockDatabase. 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 NewMockDatabase(t interface { + mock.TestingT + Cleanup(func()) +}) *MockDatabase { + mock := &MockDatabase{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockDatabase is an autogenerated mock type for the Database type +type MockDatabase struct { + mock.Mock +} + +type MockDatabase_Expecter struct { + mock *mock.Mock +} + +func (_m *MockDatabase) EXPECT() *MockDatabase_Expecter { + return &MockDatabase_Expecter{mock: &_m.Mock} +} + +// GetPermission provides a mock function for the type MockDatabase +func (_mock *MockDatabase) GetPermission(ctx context.Context, id int) (services.Permission, error) { + ret := _mock.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetPermission") + } + + var r0 services.Permission + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int) (services.Permission, error)); ok { + return returnFunc(ctx, id) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int) services.Permission); ok { + r0 = returnFunc(ctx, id) + } else { + r0 = ret.Get(0).(services.Permission) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = returnFunc(ctx, id) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockDatabase_GetPermission_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPermission' +type MockDatabase_GetPermission_Call struct { + *mock.Call +} + +// GetPermission is a helper method to define mock.On call +// - ctx context.Context +// - id int +func (_e *MockDatabase_Expecter) GetPermission(ctx interface{}, id interface{}) *MockDatabase_GetPermission_Call { + return &MockDatabase_GetPermission_Call{Call: _e.mock.On("GetPermission", ctx, id)} +} + +func (_c *MockDatabase_GetPermission_Call) Run(run func(ctx context.Context, id int)) *MockDatabase_GetPermission_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int + if args[1] != nil { + arg1 = args[1].(int) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockDatabase_GetPermission_Call) Return(permission services.Permission, err error) *MockDatabase_GetPermission_Call { + _c.Call.Return(permission, err) + return _c +} + +func (_c *MockDatabase_GetPermission_Call) RunAndReturn(run func(ctx context.Context, id int) (services.Permission, error)) *MockDatabase_GetPermission_Call { + _c.Call.Return(run) + return _c +} + +// GetRole provides a mock function for the type MockDatabase +func (_mock *MockDatabase) GetRole(ctx context.Context, id int32) (services.Role, error) { + ret := _mock.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetRole") + } + + var r0 services.Role + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int32) (services.Role, error)); ok { + return returnFunc(ctx, id) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int32) services.Role); ok { + r0 = returnFunc(ctx, id) + } else { + r0 = ret.Get(0).(services.Role) + } + 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_GetRole_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRole' +type MockDatabase_GetRole_Call struct { + *mock.Call +} + +// GetRole is a helper method to define mock.On call +// - ctx context.Context +// - id int32 +func (_e *MockDatabase_Expecter) GetRole(ctx interface{}, id interface{}) *MockDatabase_GetRole_Call { + return &MockDatabase_GetRole_Call{Call: _e.mock.On("GetRole", ctx, id)} +} + +func (_c *MockDatabase_GetRole_Call) Run(run func(ctx context.Context, id int32)) *MockDatabase_GetRole_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_GetRole_Call) Return(role services.Role, err error) *MockDatabase_GetRole_Call { + _c.Call.Return(role, err) + return _c +} + +func (_c *MockDatabase_GetRole_Call) RunAndReturn(run func(ctx context.Context, id int32) (services.Role, error)) *MockDatabase_GetRole_Call { + _c.Call.Return(run) + return _c +} + +// GetUser provides a mock function for the type MockDatabase +func (_mock *MockDatabase) GetUser(ctx context.Context, id uuid.UUID) (services.User, error) { + ret := _mock.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetUser") + } + + var r0 services.User + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID) (services.User, error)); ok { + return returnFunc(ctx, id) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID) services.User); ok { + r0 = returnFunc(ctx, id) + } else { + r0 = ret.Get(0).(services.User) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok { + r1 = returnFunc(ctx, id) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockDatabase_GetUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUser' +type MockDatabase_GetUser_Call struct { + *mock.Call +} + +// GetUser is a helper method to define mock.On call +// - ctx context.Context +// - id uuid.UUID +func (_e *MockDatabase_Expecter) GetUser(ctx interface{}, id interface{}) *MockDatabase_GetUser_Call { + return &MockDatabase_GetUser_Call{Call: _e.mock.On("GetUser", ctx, id)} +} + +func (_c *MockDatabase_GetUser_Call) Run(run func(ctx context.Context, id uuid.UUID)) *MockDatabase_GetUser_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 uuid.UUID + if args[1] != nil { + arg1 = args[1].(uuid.UUID) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockDatabase_GetUser_Call) Return(user services.User, err error) *MockDatabase_GetUser_Call { + _c.Call.Return(user, err) + return _c +} + +func (_c *MockDatabase_GetUser_Call) RunAndReturn(run func(ctx context.Context, id uuid.UUID) (services.User, error)) *MockDatabase_GetUser_Call { + _c.Call.Return(run) + return _c +} diff --git a/server/identity/internal/services/services.go b/server/identity/internal/services/services.go index 354dc149e46..e01bdc021b7 100644 --- a/server/identity/internal/services/services.go +++ b/server/identity/internal/services/services.go @@ -1,9 +1,30 @@ +// 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 services +//go:generate go tool mockery + import ( + "context" "database/sql" "errors" "time" + + "github.com/gofrs/uuid" ) type Permission struct { @@ -15,5 +36,57 @@ type Permission struct { DeletedAt sql.NullTime `json:"deleted_at"` } -// ErrNoPermissionFound indicates that there is no analysis request currently pending. +// ErrNoPermissionFound indicates that no permission with the given ID was found. var ErrNoPermissionFound = errors.New("no permission was found") + +type Role struct { + ID int32 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []Permission `json:"permissions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +// ErrNoRoleFound indicates that no role with the given ID was found. +var ErrNoRoleFound = errors.New("no role was found") + +type User struct { + ID uuid.UUID `json:"id"` + PrincipalName string `json:"principal_name"` + IsDisabled bool `json:"is_disabled"` + EULAAccepted bool `json:"eula_accepted"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +// ErrNoUserFound indicates that no user with the given ID was found. +var ErrNoUserFound = errors.New("no user was found") + +type Database interface { + GetRole(ctx context.Context, id int32) (Role, error) + GetUser(ctx context.Context, id uuid.UUID) (User, error) + GetPermission(ctx context.Context, id int) (Permission, error) +} + +type Service struct { + db Database +} + +func NewService(databaseInterface Database) *Service { + return &Service{db: databaseInterface} +} + +func (s *Service) GetRole(ctx context.Context, id int32) (Role, error) { + return s.db.GetRole(ctx, id) +} + +func (s *Service) GetUser(ctx context.Context, id uuid.UUID) (User, error) { + return s.db.GetUser(ctx, id) +} + +func (s *Service) GetPermission(ctx context.Context, id int) (Permission, error) { + return s.db.GetPermission(ctx, id) +} diff --git a/server/identity/internal/services/services_test.go b/server/identity/internal/services/services_test.go new file mode 100644 index 00000000000..ed0a7afdb80 --- /dev/null +++ b/server/identity/internal/services/services_test.go @@ -0,0 +1,148 @@ +// 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 services_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/specterops/bloodhound/server/identity/internal/services" + "github.com/specterops/bloodhound/server/identity/internal/services/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestService_GetPermission(t *testing.T) { + var ( + ctx = context.Background() + permissionID = 7 + unexpectedErr = errors.New("connection refused") + expected = services.Permission{ + ID: 7, + Authority: "app", + Name: "ManageProviders", + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), + } + ) + + tests := []struct { + name string + dbResult services.Permission + dbErr error + wantResult services.Permission + wantErr error + }{ + { + name: "returns the permission on success", + dbResult: expected, + wantResult: expected, + }, + { + name: "propagates ErrNoPermissionFound", + dbErr: services.ErrNoPermissionFound, + wantErr: services.ErrNoPermissionFound, + }, + { + name: "propagates unexpected 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().GetPermission(ctx, permissionID).Return(tt.dbResult, tt.dbErr) + + result, err := svc.GetPermission(ctx, permissionID) + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantResult, result) + } + }) + } +} + +func TestService_GetRole(t *testing.T) { + var ( + ctx = context.Background() + roleID = int32(3) + unexpectedErr = errors.New("connection refused") + expected = services.Role{ + ID: 3, + Name: "Administrator", + Description: "Can manage the application", + Permissions: []services.Permission{ + {ID: 1, Authority: "app", Name: "ManageProviders"}, + }, + CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), + } + ) + + tests := []struct { + name string + dbResult services.Role + dbErr error + wantResult services.Role + wantErr error + }{ + { + name: "returns the role on success", + dbResult: expected, + wantResult: expected, + }, + { + name: "propagates ErrNoRoleFound", + dbErr: services.ErrNoRoleFound, + wantErr: services.ErrNoRoleFound, + }, + { + name: "propagates unexpected 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().GetRole(ctx, roleID).Return(tt.dbResult, tt.dbErr) + + result, err := svc.GetRole(ctx, roleID) + if tt.wantErr != nil { + assert.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantResult, result) + } + }) + } +} From 13fdd61c40b711197025514a26b5fd41250338b4 Mon Sep 17 00:00:00 2001 From: Stephanie Lamb Date: Tue, 30 Jun 2026 10:50:09 -0500 Subject: [PATCH 3/5] added remaining layers --- server/identity/identity.go | 22 ++ server/identity/identity_test.go | 62 ++++++ server/identity/internal/handlers/handlers.go | 105 +++++++++ .../internal/handlers/handlers_test.go | 199 ++++++++++++++++++ .../internal/handlers/mocks/identity.go | 171 +++++++++++++++ server/identity/internal/handlers/views.go | 94 +++++++++ server/identity/internal/routes/routes.go | 34 +++ .../identity/internal/routes/routes_test.go | 95 +++++++++ server/modules/modules.go | 2 + 9 files changed, 784 insertions(+) create mode 100644 server/identity/identity_test.go create mode 100644 server/identity/internal/handlers/handlers.go create mode 100644 server/identity/internal/handlers/handlers_test.go create mode 100644 server/identity/internal/handlers/mocks/identity.go create mode 100644 server/identity/internal/handlers/views.go create mode 100644 server/identity/internal/routes/routes.go create mode 100644 server/identity/internal/routes/routes_test.go diff --git a/server/identity/identity.go b/server/identity/identity.go index cca56a06aef..2c455a2c116 100644 --- a/server/identity/identity.go +++ b/server/identity/identity.go @@ -1 +1,23 @@ package identity + +import ( + "github.com/jackc/pgx/v5/pgxpool" + "github.com/specterops/bloodhound/cmd/api/src/api/router" + "github.com/specterops/bloodhound/server/identity/internal/appdb" + "github.com/specterops/bloodhound/server/identity/internal/handlers" + "github.com/specterops/bloodhound/server/identity/internal/routes" + "github.com/specterops/bloodhound/server/identity/internal/services" +) + +// Register builds the analysis store -> service -> handler chain and attaches +// the analysis routes to the provided router. It is called from the modules +// registry and receives only the infrastructure it directly needs. +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/identity/identity_test.go b/server/identity/identity_test.go new file mode 100644 index 00000000000..219f38ddc72 --- /dev/null +++ b/server/identity/identity_test.go @@ -0,0 +1,62 @@ +// 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 identity_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/identity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegister(t *testing.T) { + t.Run("successfully registers identity 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() { + identity.Register(&routerInst, pool) + }) + + // Verify routes are registered + var ( + muxRouter = routerInst.MuxRouter() + match mux.RouteMatch + ) + + // Test GET /api/v2/roles/{role_id} route + getRoleRequest := httptest.NewRequest(http.MethodGet, "/api/v2/roles/1", nil) + assert.True(t, muxRouter.Match(getRoleRequest, &match), "GET /api/v2/roles/{role_id} route should be registered") + + // Test GET /api/v2/permissions/{permission_id} route + getPermissionRequest := httptest.NewRequest(http.MethodGet, "/api/v2/permissions/1", nil) + assert.True(t, muxRouter.Match(getPermissionRequest, &match), "GET /api/v2/permissions/{permission_id} route should be registered") + }) +} diff --git a/server/identity/internal/handlers/handlers.go b/server/identity/internal/handlers/handlers.go new file mode 100644 index 00000000000..5d108dae867 --- /dev/null +++ b/server/identity/internal/handlers/handlers.go @@ -0,0 +1,105 @@ +// 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" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/specterops/bloodhound/cmd/api/src/api" + "github.com/specterops/bloodhound/packages/go/responses" + "github.com/specterops/bloodhound/server/identity/internal/services" +) + +// Identity defines the identity service boundary for the identity handlers package. +type Identity interface { + GetRole(ctx context.Context, id int32) (services.Role, error) + GetPermission(ctx context.Context, id int) (services.Permission, error) +} + +// Handlers is a dependency injection container for identity handlers. +type Handlers struct { + identity Identity +} + +// NewHandlersContainer initializes the Handlers dependency injection container +func NewHandlersContainer(identity Identity) *Handlers { + return &Handlers{ + identity: identity, + } +} + +// GetPermission returns the permission for the id in the request path. +func (s *Handlers) GetPermission(response http.ResponseWriter, request *http.Request) { + var ( + ctx = request.Context() + rawPermissionID = mux.Vars(request)[api.URIPathVariablePermissionID] + ) + + permissionID, err := strconv.Atoi(rawPermissionID) + if err != nil { + responses.WriteError(ctx, http.StatusBadRequest, api.ErrorResponseDetailsIDMalformed, response) + return + } + + permission, err := s.identity.GetPermission(ctx, permissionID) + if err != nil { + handleIdentityError(request, response, err) + return + } + + responses.WriteBasic(ctx, BuildPermissionView(permission), http.StatusOK, response) +} + +// GetRole returns the role for the id in the request path. +func (s *Handlers) GetRole(response http.ResponseWriter, request *http.Request) { + var ( + ctx = request.Context() + rawRoleID = mux.Vars(request)[api.URIPathVariableRoleID] + ) + + roleID, err := strconv.ParseInt(rawRoleID, 10, 32) + if err != nil { + responses.WriteError(ctx, http.StatusBadRequest, api.ErrorResponseDetailsIDMalformed, response) + return + } + + role, err := s.identity.GetRole(ctx, int32(roleID)) + if err != nil { + handleIdentityError(request, response, err) + return + } + + responses.WriteBasic(ctx, BuildRoleView(role), http.StatusOK, response) +} + +func handleIdentityError(request *http.Request, response http.ResponseWriter, err error) { + var ctx = request.Context() + + if errors.Is(err, services.ErrNoRoleFound) || errors.Is(err, services.ErrNoPermissionFound) { + responses.WriteError(ctx, http.StatusNotFound, api.ErrorResponseDetailsResourceNotFound, response) + } else if errors.Is(err, context.DeadlineExceeded) { + responses.WriteError(ctx, http.StatusInternalServerError, api.ErrorResponseRequestTimeout, response) + } else { + responses.WriteInternalServerError(ctx, err, response) + } +} diff --git a/server/identity/internal/handlers/handlers_test.go b/server/identity/internal/handlers/handlers_test.go new file mode 100644 index 00000000000..6092c9764dc --- /dev/null +++ b/server/identity/internal/handlers/handlers_test.go @@ -0,0 +1,199 @@ +// 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/gorilla/mux" + "github.com/specterops/bloodhound/server/identity/internal/handlers" + "github.com/specterops/bloodhound/server/identity/internal/handlers/mocks" + "github.com/specterops/bloodhound/server/identity/internal/services" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newRequestWithVars(t *testing.T, target string, vars map[string]string) *http.Request { + t.Helper() + req, err := http.NewRequest(http.MethodGet, target, nil) + require.NoError(t, err) + return mux.SetURLVars(req, vars) +} + +func TestHandlers_GetPermission(t *testing.T) { + var ( + unexpectedErr = errors.New("unexpected database failure") + expected = services.Permission{ID: 7, Authority: "app", Name: "ManageProviders"} + ) + + tests := []struct { + name string + rawID string + expect func(m *mocks.MockIdentity, ctx context.Context) + wantStatus int + assertBody func(t *testing.T, body []byte) + }{ + { + name: "returns 200 with the permission view on success", + rawID: "7", + expect: func(m *mocks.MockIdentity, ctx context.Context) { + m.EXPECT().GetPermission(ctx, 7).Return(expected, nil) + }, + wantStatus: http.StatusOK, + assertBody: func(t *testing.T, body []byte) { + var envelope struct { + Data handlers.PermissionView `json:"data"` + } + require.NoError(t, json.Unmarshal(body, &envelope)) + assert.Equal(t, expected.ID, envelope.Data.ID) + assert.Equal(t, expected.Authority, envelope.Data.Authority) + assert.Equal(t, expected.Name, envelope.Data.Name) + }, + }, + { + name: "returns 400 for a malformed permission ID", + rawID: "not-an-int", + wantStatus: http.StatusBadRequest, + }, + { + name: "returns 404 when the permission does not exist", + rawID: "7", + expect: func(m *mocks.MockIdentity, ctx context.Context) { + m.EXPECT().GetPermission(ctx, 7).Return(services.Permission{}, services.ErrNoPermissionFound) + }, + wantStatus: http.StatusNotFound, + }, + { + name: "returns 500 on unexpected service error", + rawID: "7", + expect: func(m *mocks.MockIdentity, ctx context.Context) { + m.EXPECT().GetPermission(ctx, 7).Return(services.Permission{}, unexpectedErr) + }, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + identityMock = mocks.NewMockIdentity(t) + handlerSet = handlers.NewHandlersContainer(identityMock) + recorder = httptest.NewRecorder() + request = newRequestWithVars(t, "/api/v2/permissions/"+tt.rawID, map[string]string{"permission_id": tt.rawID}) + ) + + if tt.expect != nil { + tt.expect(identityMock, request.Context()) + } + + handlerSet.GetPermission(recorder, request) + + assert.Equal(t, tt.wantStatus, recorder.Code) + if tt.assertBody != nil { + tt.assertBody(t, recorder.Body.Bytes()) + } + }) + } +} + +func TestHandlers_GetRole(t *testing.T) { + var ( + unexpectedErr = errors.New("unexpected database failure") + expected = services.Role{ + ID: 3, + Name: "Administrator", + Description: "Can manage the application", + Permissions: []services.Permission{{ID: 1, Authority: "app", Name: "ManageProviders"}}, + } + ) + + tests := []struct { + name string + rawID string + expect func(m *mocks.MockIdentity, ctx context.Context) + wantStatus int + assertBody func(t *testing.T, body []byte) + }{ + { + name: "returns 200 with the role view on success", + rawID: "3", + expect: func(m *mocks.MockIdentity, ctx context.Context) { + m.EXPECT().GetRole(ctx, int32(3)).Return(expected, nil) + }, + wantStatus: http.StatusOK, + assertBody: func(t *testing.T, body []byte) { + var envelope struct { + Data handlers.RoleView `json:"data"` + } + require.NoError(t, json.Unmarshal(body, &envelope)) + assert.Equal(t, expected.ID, envelope.Data.ID) + assert.Equal(t, expected.Name, envelope.Data.Name) + assert.Equal(t, expected.Description, envelope.Data.Description) + require.Len(t, envelope.Data.Permissions, 1) + assert.Equal(t, expected.Permissions[0].Name, envelope.Data.Permissions[0].Name) + }, + }, + { + name: "returns 400 for a malformed role ID", + rawID: "not-an-int", + wantStatus: http.StatusBadRequest, + }, + { + name: "returns 404 when the role does not exist", + rawID: "3", + expect: func(m *mocks.MockIdentity, ctx context.Context) { + m.EXPECT().GetRole(ctx, int32(3)).Return(services.Role{}, services.ErrNoRoleFound) + }, + wantStatus: http.StatusNotFound, + }, + { + name: "returns 500 on unexpected service error", + rawID: "3", + expect: func(m *mocks.MockIdentity, ctx context.Context) { + m.EXPECT().GetRole(ctx, int32(3)).Return(services.Role{}, unexpectedErr) + }, + wantStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + identityMock = mocks.NewMockIdentity(t) + handlerSet = handlers.NewHandlersContainer(identityMock) + recorder = httptest.NewRecorder() + request = newRequestWithVars(t, "/api/v2/roles/"+tt.rawID, map[string]string{"role_id": tt.rawID}) + ) + + if tt.expect != nil { + tt.expect(identityMock, request.Context()) + } + + handlerSet.GetRole(recorder, request) + + assert.Equal(t, tt.wantStatus, recorder.Code) + if tt.assertBody != nil { + tt.assertBody(t, recorder.Body.Bytes()) + } + }) + } +} diff --git a/server/identity/internal/handlers/mocks/identity.go b/server/identity/internal/handlers/mocks/identity.go new file mode 100644 index 00000000000..960637f9759 --- /dev/null +++ b/server/identity/internal/handlers/mocks/identity.go @@ -0,0 +1,171 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/specterops/bloodhound/server/identity/internal/services" + mock "github.com/stretchr/testify/mock" +) + +// NewMockIdentity creates a new instance of MockIdentity. 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 NewMockIdentity(t interface { + mock.TestingT + Cleanup(func()) +}) *MockIdentity { + mock := &MockIdentity{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockIdentity is an autogenerated mock type for the Identity type +type MockIdentity struct { + mock.Mock +} + +type MockIdentity_Expecter struct { + mock *mock.Mock +} + +func (_m *MockIdentity) EXPECT() *MockIdentity_Expecter { + return &MockIdentity_Expecter{mock: &_m.Mock} +} + +// GetPermission provides a mock function for the type MockIdentity +func (_mock *MockIdentity) GetPermission(ctx context.Context, id int) (services.Permission, error) { + ret := _mock.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetPermission") + } + + var r0 services.Permission + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int) (services.Permission, error)); ok { + return returnFunc(ctx, id) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int) services.Permission); ok { + r0 = returnFunc(ctx, id) + } else { + r0 = ret.Get(0).(services.Permission) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = returnFunc(ctx, id) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockIdentity_GetPermission_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPermission' +type MockIdentity_GetPermission_Call struct { + *mock.Call +} + +// GetPermission is a helper method to define mock.On call +// - ctx context.Context +// - id int +func (_e *MockIdentity_Expecter) GetPermission(ctx interface{}, id interface{}) *MockIdentity_GetPermission_Call { + return &MockIdentity_GetPermission_Call{Call: _e.mock.On("GetPermission", ctx, id)} +} + +func (_c *MockIdentity_GetPermission_Call) Run(run func(ctx context.Context, id int)) *MockIdentity_GetPermission_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 int + if args[1] != nil { + arg1 = args[1].(int) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *MockIdentity_GetPermission_Call) Return(permission services.Permission, err error) *MockIdentity_GetPermission_Call { + _c.Call.Return(permission, err) + return _c +} + +func (_c *MockIdentity_GetPermission_Call) RunAndReturn(run func(ctx context.Context, id int) (services.Permission, error)) *MockIdentity_GetPermission_Call { + _c.Call.Return(run) + return _c +} + +// GetRole provides a mock function for the type MockIdentity +func (_mock *MockIdentity) GetRole(ctx context.Context, id int32) (services.Role, error) { + ret := _mock.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetRole") + } + + var r0 services.Role + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, int32) (services.Role, error)); ok { + return returnFunc(ctx, id) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, int32) services.Role); ok { + r0 = returnFunc(ctx, id) + } else { + r0 = ret.Get(0).(services.Role) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, int32) error); ok { + r1 = returnFunc(ctx, id) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockIdentity_GetRole_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRole' +type MockIdentity_GetRole_Call struct { + *mock.Call +} + +// GetRole is a helper method to define mock.On call +// - ctx context.Context +// - id int32 +func (_e *MockIdentity_Expecter) GetRole(ctx interface{}, id interface{}) *MockIdentity_GetRole_Call { + return &MockIdentity_GetRole_Call{Call: _e.mock.On("GetRole", ctx, id)} +} + +func (_c *MockIdentity_GetRole_Call) Run(run func(ctx context.Context, id int32)) *MockIdentity_GetRole_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 *MockIdentity_GetRole_Call) Return(role services.Role, err error) *MockIdentity_GetRole_Call { + _c.Call.Return(role, err) + return _c +} + +func (_c *MockIdentity_GetRole_Call) RunAndReturn(run func(ctx context.Context, id int32) (services.Role, error)) *MockIdentity_GetRole_Call { + _c.Call.Return(run) + return _c +} diff --git a/server/identity/internal/handlers/views.go b/server/identity/internal/handlers/views.go new file mode 100644 index 00000000000..ec5f539fc56 --- /dev/null +++ b/server/identity/internal/handlers/views.go @@ -0,0 +1,94 @@ +// 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 ( + "database/sql" + "encoding/json" + "time" + + "github.com/specterops/bloodhound/server/identity/internal/services" +) + +// PermissionView is the JSON shape returned by the identity handlers for a +// permission. It is decoupled from services.Permission so the wire format can +// evolve independently of the domain model. +type PermissionView struct { + Authority string `json:"authority"` + Name string `json:"name"` + ID int32 `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +// BuildPermissionView projects a services.Permission into the view type the +// handlers return in their JSON envelope. +func BuildPermissionView(permission services.Permission) PermissionView { + return PermissionView{ + Authority: permission.Authority, + Name: permission.Name, + ID: permission.ID, + CreatedAt: permission.CreatedAt, + UpdatedAt: permission.UpdatedAt, + DeletedAt: permission.DeletedAt, + } +} + +// JSONView marshals the view to the byte slice expected by responses.WriteBasic, +// satisfying the responses.JSONViewer contract. +func (s PermissionView) JSONView() ([]byte, error) { + return json.Marshal(s) +} + +// RoleView is the JSON shape returned by the identity handlers for a role. It is +// decoupled from services.Role so the wire format can evolve independently of +// the domain model. +type RoleView struct { + ID int32 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Permissions []PermissionView `json:"permissions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt sql.NullTime `json:"deleted_at"` +} + +// BuildRoleView projects a services.Role into the view type the handlers return +// in their JSON envelope. +func BuildRoleView(role services.Role) RoleView { + var permissions = make([]PermissionView, 0, len(role.Permissions)) + for _, permission := range role.Permissions { + permissions = append(permissions, BuildPermissionView(permission)) + } + + return RoleView{ + ID: role.ID, + Name: role.Name, + Description: role.Description, + Permissions: permissions, + CreatedAt: role.CreatedAt, + UpdatedAt: role.UpdatedAt, + DeletedAt: role.DeletedAt, + } +} + +// JSONView marshals the view to the byte slice expected by responses.WriteBasic, +// satisfying the responses.JSONViewer contract. +func (s RoleView) JSONView() ([]byte, error) { + return json.Marshal(s) +} diff --git a/server/identity/internal/routes/routes.go b/server/identity/internal/routes/routes.go new file mode 100644 index 00000000000..3fd07119508 --- /dev/null +++ b/server/identity/internal/routes/routes.go @@ -0,0 +1,34 @@ +// 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 ( + "fmt" + + "github.com/specterops/bloodhound/cmd/api/src/api" + "github.com/specterops/bloodhound/cmd/api/src/api/router" + "github.com/specterops/bloodhound/cmd/api/src/auth" + "github.com/specterops/bloodhound/server/identity/internal/handlers" +) + +// Register attaches the identity endpoints to the given router instance. +func Register(routerInst *router.Router, handlers *handlers.Handlers) { + var permissions = auth.Permissions() + + routerInst.GET(fmt.Sprintf("/api/v2/roles/{%s}", api.URIPathVariableRoleID), handlers.GetRole).RequirePermissions(permissions.AuthManageSelf) + routerInst.GET(fmt.Sprintf("/api/v2/permissions/{%s}", api.URIPathVariablePermissionID), handlers.GetPermission).RequirePermissions(permissions.AuthManageSelf) +} diff --git a/server/identity/internal/routes/routes_test.go b/server/identity/internal/routes/routes_test.go new file mode 100644 index 00000000000..a047f545118 --- /dev/null +++ b/server/identity/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/identity/internal/handlers" + "github.com/specterops/bloodhound/server/identity/internal/handlers/mocks" + "github.com/specterops/bloodhound/server/identity/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, "") + mock = mocks.NewMockIdentity(t) + handlerSet = handlers.NewHandlersContainer(mock) + ) + + routes.Register(&routerInst, handlerSet) + + muxRouter := routerInst.MuxRouter() + + for _, tc := range []struct { + method string + path string + }{ + {http.MethodGet, "/api/v2/roles/1"}, + {http.MethodGet, "/api/v2/permissions/1"}, + } { + 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 unauthenticated requests +// through the wired router to verify that the registered routes are guarded by +// authentication middleware. If the route wireup ever loses RequirePermissions, +// this test will fail. +func TestRegister_RoutesRequireAuthentication(t *testing.T) { + var ( + cfg = config.Configuration{} + authorizer = auth.NewAuthorizer(nil) + routerInst = router.NewRouter(cfg, authorizer, "") + mock = mocks.NewMockIdentity(t) + handlerSet = handlers.NewHandlersContainer(mock) + ) + + routes.Register(&routerInst, handlerSet) + handler := routerInst.Handler() + + for _, tc := range []struct { + method string + path string + }{ + {http.MethodGet, "/api/v2/roles/1"}, + {http.MethodGet, "/api/v2/permissions/1"}, + } { + 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/modules/modules.go b/server/modules/modules.go index 4c710587704..601599583dd 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/identity" ) // 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) + identity.Register(deps.Router, deps.Pool) } From e5a255355ddc2b343890a64c53869388c9ec16fd Mon Sep 17 00:00:00 2001 From: Stephanie Lamb Date: Tue, 30 Jun 2026 14:59:44 -0500 Subject: [PATCH 4/5] removed getUserByID. updated e2e test --- .../internal/handlers/mocks/analysis.go | 15 +++ .../internal/services/mocks/database.go | 15 +++ .../analysis/mocks/analysisrequestadapter.go | 15 +++ .../internal/services/mocks/database.go | 15 +++ server/featureflags/mocks/service.go | 15 +++ server/identity/identity.go | 15 +++ server/identity/identity_e2e_test.go | 103 +++++------------- server/identity/internal/appdb/appdb.go | 74 +++---------- .../internal/appdb/appdb_integration_test.go | 47 -------- server/identity/internal/appdb/appdb_test.go | 84 -------------- .../internal/handlers/mocks/identity.go | 15 +++ .../internal/services/mocks/database.go | 82 +++----------- server/identity/internal/services/services.go | 20 ---- 13 files changed, 161 insertions(+), 354 deletions(-) diff --git a/server/analysis/internal/handlers/mocks/analysis.go b/server/analysis/internal/handlers/mocks/analysis.go index 735bce272e1..1fb8e1a2718 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 e87b72e7dc8..b52b9f8cc10 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 6bdad1ceb35..f154dbf182b 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/internal/services/mocks/database.go b/server/featureflags/internal/services/mocks/database.go index 24045c1455a..3a655dfc46e 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/mocks/service.go b/server/featureflags/mocks/service.go index 1d3fee9b358..b73d9f9f8ff 100644 --- a/server/featureflags/mocks/service.go +++ b/server/featureflags/mocks/service.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/identity/identity.go b/server/identity/identity.go index 2c455a2c116..957527f1bc0 100644 --- a/server/identity/identity.go +++ b/server/identity/identity.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 identity import ( diff --git a/server/identity/identity_e2e_test.go b/server/identity/identity_e2e_test.go index 00097989a40..2cb62cc5745 100644 --- a/server/identity/identity_e2e_test.go +++ b/server/identity/identity_e2e_test.go @@ -28,16 +28,16 @@ import ( "strings" "testing" - "github.com/gofrs/uuid" "github.com/gorilla/mux" "github.com/peterldowns/pgtestdb" - authapi "github.com/specterops/bloodhound/cmd/api/src/api/v2/auth" "github.com/specterops/bloodhound/cmd/api/src/auth" "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/services/dogtags" "github.com/specterops/bloodhound/cmd/api/src/test/integration/utils" + "github.com/specterops/bloodhound/server/identity/internal/appdb" + "github.com/specterops/bloodhound/server/identity/internal/handlers" + "github.com/specterops/bloodhound/server/identity/internal/services" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -108,24 +108,15 @@ func getIdentityPostgresConfig(t *testing.T) pgtestdb.Config { } } -// createTestUser inserts a minimal user into the database for use in tests. -func createTestUser(t *testing.T, ctx context.Context, db *database.BloodhoundDB, principalName string) model.User { - t.Helper() - user, err := db.CreateUser(ctx, model.User{ - PrincipalName: principalName, - EULAAccepted: true, - AllEnvironments: true, - }) - require.NoError(t, err) - return user -} - -// newManagementResource wires a real ManagementResource backed by the given database. -func newManagementResource(t *testing.T, db *database.BloodhoundDB) authapi.ManagementResource { - t.Helper() - cfg, err := config.NewDefaultConfiguration() - require.NoError(t, err) - return authapi.NewManagementResource(cfg, db, auth.NewAuthorizer(db), nil, nil, dogtags.NewDefaultService()) +// newIdentityHandlers wires the identity store -> service -> handlers chain +// backed by the given database. +func newIdentityHandlers(db *database.BloodhoundDB) *handlers.Handlers { + var ( + store = appdb.NewStore(db.Pool()) + svc = services.NewService(store) + handlerSet = handlers.NewHandlersContainer(svc) + ) + return handlerSet } // permissionResponseEnvelope is the JSON envelope shape returned by the @@ -140,18 +131,12 @@ type roleResponseEnvelope struct { Data model.Role `json:"data"` } -// userResponseEnvelope is the JSON envelope shape returned by the -// GET /api/v2/bloodhound-users/{user_id} handler. -type userResponseEnvelope struct { - Data model.User `json:"data"` -} - func TestGetPermission(t *testing.T) { var ( db = setupIdentityDB(t) ctx = context.Background() - resource = newManagementResource(t, db) - handler = resource.GetPermission + handlerSet = newIdentityHandlers(db) + handler = handlerSet.GetPermission permissions model.Permissions err error ) @@ -179,6 +164,9 @@ func TestGetPermission(t *testing.T) { assert.Equal(t, seededPermission.ID, envelope.Data.ID) assert.Equal(t, seededPermission.Authority, envelope.Data.Authority) assert.Equal(t, seededPermission.Name, envelope.Data.Name) + assert.True(t, seededPermission.CreatedAt.Equal(envelope.Data.CreatedAt), "created_at should match the seeded permission") + assert.True(t, seededPermission.UpdatedAt.Equal(envelope.Data.UpdatedAt), "updated_at should match the seeded permission") + assert.Equal(t, seededPermission.DeletedAt.Valid, envelope.Data.DeletedAt.Valid, "deleted_at validity should match the seeded permission") }) t.Run("returns 404 Not Found when the permission does not exist", func(t *testing.T) { @@ -196,12 +184,12 @@ func TestGetPermission(t *testing.T) { func TestGetRole(t *testing.T) { var ( - db = setupIdentityDB(t) - ctx = context.Background() - resource = newManagementResource(t, db) - handler = resource.GetRole - roles model.Roles - err error + db = setupIdentityDB(t) + ctx = context.Background() + handlerSet = newIdentityHandlers(db) + handler = handlerSet.GetRole + roles model.Roles + err error ) roles, err = db.GetAllRoles(ctx, "", model.SQLFilter{}) @@ -227,6 +215,9 @@ func TestGetRole(t *testing.T) { assert.Equal(t, seededRole.ID, envelope.Data.ID) assert.Equal(t, seededRole.Name, envelope.Data.Name) assert.NotEmpty(t, envelope.Data.Permissions, "expected the role to preload its permissions") + assert.True(t, seededRole.CreatedAt.Equal(envelope.Data.CreatedAt), "created_at should match the seeded role") + assert.True(t, seededRole.UpdatedAt.Equal(envelope.Data.UpdatedAt), "updated_at should match the seeded role") + assert.Equal(t, seededRole.DeletedAt.Valid, envelope.Data.DeletedAt.Valid, "deleted_at validity should match the seeded role") }) t.Run("returns 404 Not Found when the role does not exist", func(t *testing.T) { @@ -241,45 +232,3 @@ func TestGetRole(t *testing.T) { assert.Equal(t, http.StatusBadRequest, recorder.Code) }) } - -func TestGetUser(t *testing.T) { - var ( - db = setupIdentityDB(t) - ctx = context.Background() - user = createTestUser(t, ctx, db, "get-user-principal") - resource = newManagementResource(t, db) - handler = resource.GetUser - ) - - newRequest := func(t *testing.T, userID string) *http.Request { - t.Helper() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/api/v2/bloodhound-users/"+userID, nil) - require.NoError(t, err) - return mux.SetURLVars(req, map[string]string{"user_id": userID}) - } - - t.Run("returns 200 OK with the user for a valid ID", func(t *testing.T) { - recorder := httptest.NewRecorder() - handler(recorder, newRequest(t, user.ID.String())) - - assert.Equal(t, http.StatusOK, recorder.Code) - - var envelope userResponseEnvelope - require.NoError(t, json.NewDecoder(recorder.Body).Decode(&envelope)) - assert.Equal(t, user.ID, envelope.Data.ID) - assert.Equal(t, user.PrincipalName, envelope.Data.PrincipalName) - }) - - t.Run("returns 404 Not Found when the user does not exist", func(t *testing.T) { - nonExistentID := uuid.Must(uuid.NewV4()) - recorder := httptest.NewRecorder() - handler(recorder, newRequest(t, nonExistentID.String())) - assert.Equal(t, http.StatusNotFound, recorder.Code) - }) - - t.Run("returns 400 Bad Request for a malformed user ID", func(t *testing.T) { - recorder := httptest.NewRecorder() - handler(recorder, newRequest(t, "not-a-uuid")) - assert.Equal(t, http.StatusBadRequest, recorder.Code) - }) -} diff --git a/server/identity/internal/appdb/appdb.go b/server/identity/internal/appdb/appdb.go index dc58c738f5e..57eb0ecdfe1 100644 --- a/server/identity/internal/appdb/appdb.go +++ b/server/identity/internal/appdb/appdb.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 appdb import ( @@ -6,7 +21,6 @@ import ( "fmt" "time" - "github.com/gofrs/uuid" "github.com/huandu/go-sqlbuilder" "github.com/jackc/pgx/v5" "github.com/specterops/bloodhound/server/identity/internal/services" @@ -16,7 +30,6 @@ const ( tablePermissions = "permissions" tableRoles = "roles" tableRolesPermissions = "roles_permissions" - tableUsers = "users" ) type queryExecer interface { @@ -43,15 +56,6 @@ type role struct { UpdatedAt time.Time `db:"updated_at"` } -type user struct { - ID string `db:"id"` - PrincipalName string `db:"principal_name"` - IsDisabled bool `db:"is_disabled"` - EULAAccepted bool `db:"eula_accepted"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - type Store struct { db pgxQuerier } @@ -85,25 +89,6 @@ func toRole(row role, permissionRows []permission) services.Role { } } -func toUser(row user) (services.User, error) { - var ( - userID uuid.UUID - err error - ) - userID, err = uuid.FromString(row.ID) - if err != nil { - return services.User{}, fmt.Errorf("parsing user id: %w", err) - } - return services.User{ - ID: userID, - PrincipalName: row.PrincipalName, - IsDisabled: row.IsDisabled, - EULAAccepted: row.EULAAccepted, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - }, nil -} - func (s *Store) GetRole(ctx context.Context, id int32) (services.Role, error) { var ( roleSB = sqlbuilder.PostgreSQL.NewSelectBuilder() @@ -145,35 +130,6 @@ func (s *Store) GetRole(ctx context.Context, id int32) (services.Role, error) { return toRole(roleRow, permissionRows), nil } -func (s *Store) GetUser(ctx context.Context, id uuid.UUID) (services.User, error) { - var ( - userSB = sqlbuilder.PostgreSQL.NewSelectBuilder() - userRows pgx.Rows - userRow user - err error - ) - - userSB.Select("id", "principal_name", "is_disabled", "eula_accepted", "created_at", "updated_at"). - From(tableUsers). - Where(userSB.Equal("id", id.String())). - Limit(1) - userQuery, userArgs := userSB.Build() - - userRows, err = s.db.Query(ctx, userQuery, userArgs...) - if err != nil { - return services.User{}, err - } - userRow, err = pgx.CollectOneRow(userRows, pgx.RowToStructByName[user]) - if errors.Is(err, pgx.ErrNoRows) { - return services.User{}, services.ErrNoUserFound - } - if err != nil { - return services.User{}, fmt.Errorf("finding user: %s", err) - } - - return toUser(userRow) -} - func (s *Store) GetPermission(ctx context.Context, id int) (services.Permission, error) { sb := sqlbuilder.PostgreSQL.NewSelectBuilder() sb.Select("*") diff --git a/server/identity/internal/appdb/appdb_integration_test.go b/server/identity/internal/appdb/appdb_integration_test.go index 83d79d3a405..fb70949a640 100644 --- a/server/identity/internal/appdb/appdb_integration_test.go +++ b/server/identity/internal/appdb/appdb_integration_test.go @@ -25,7 +25,6 @@ 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" @@ -131,24 +130,6 @@ func seededRole(t *testing.T, ctx context.Context, pool *pgxpool.Pool) services. return found } -// insertTestUser inserts a minimal user row and returns its UUID. -func insertTestUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, principalName string) uuid.UUID { - t.Helper() - - var userID uuid.UUID - userID, err := uuid.NewV4() - require.NoError(t, err) - - _, err = pool.Exec(ctx, - "INSERT INTO users (id, principal_name, is_disabled, eula_accepted, created_at, updated_at) VALUES ($1, $2, false, false, NOW(), NOW())", - userID.String(), - principalName, - ) - require.NoError(t, err) - - return userID -} - func TestStore_GetPermission_Integration(t *testing.T) { t.Run("returns the permission for a seeded id", func(t *testing.T) { var ( @@ -202,31 +183,3 @@ func TestStore_GetRole_Integration(t *testing.T) { assert.ErrorIs(t, err, services.ErrNoRoleFound) }) } - -func TestStore_GetUser_Integration(t *testing.T) { - t.Run("returns the user for a valid id", func(t *testing.T) { - var ( - ctx = context.Background() - store, pool = setupStoreAndPool(t) - principalName = "integration-test-user@example.com" - ) - - userID := insertTestUser(t, ctx, pool, principalName) - - retrieved, err := store.GetUser(ctx, userID) - require.NoError(t, err) - assert.Equal(t, userID, retrieved.ID) - assert.Equal(t, principalName, retrieved.PrincipalName) - }) - - t.Run("returns ErrNoUserFound when the user does not exist", func(t *testing.T) { - var ( - ctx = context.Background() - store, _ = setupStoreAndPool(t) - ) - - nonExistentID := uuid.Must(uuid.NewV4()) - _, err := store.GetUser(ctx, nonExistentID) - assert.ErrorIs(t, err, services.ErrNoUserFound) - }) -} diff --git a/server/identity/internal/appdb/appdb_test.go b/server/identity/internal/appdb/appdb_test.go index c5180fa621e..ab50ed4406b 100644 --- a/server/identity/internal/appdb/appdb_test.go +++ b/server/identity/internal/appdb/appdb_test.go @@ -22,7 +22,6 @@ import ( "testing" "time" - "github.com/gofrs/uuid" "github.com/pashagolub/pgxmock/v4" "github.com/specterops/bloodhound/server/identity/internal/appdb" "github.com/specterops/bloodhound/server/identity/internal/services" @@ -40,9 +39,6 @@ const expectedGetRoleSQL = `SELECT * FROM roles WHERE id = $1 LIMIT $2` // query in GetRole. const expectedGetRolePermissionsSQL = `SELECT p.id, p.authority, p.name, p.created_at, p.updated_at FROM permissions p JOIN roles_permissions rp ON rp.permission_id = p.id WHERE rp.role_id = $1` -// expectedGetUserSQL is the literal SQL the Store issues for GetUser. -const expectedGetUserSQL = `SELECT id, principal_name, is_disabled, eula_accepted, created_at, updated_at FROM users WHERE id = $1 LIMIT $2` - func newTestStore(t *testing.T) (*appdb.Store, pgxmock.PgxPoolIface) { t.Helper() pool, err := pgxmock.NewPool(pgxmock.QueryMatcherOption(pgxmock.QueryMatcherEqual)) @@ -248,83 +244,3 @@ func TestStore_GetRole(t *testing.T) { }) } } - -func userRowColumns() []string { - return []string{"id", "principal_name", "is_disabled", "eula_accepted", "created_at", "updated_at"} -} - -func TestStore_GetUser(t *testing.T) { - var ( - ctx = context.Background() - dbErr = errors.New("connection refused") - userID = uuid.Must(uuid.NewV4()) - expected = services.User{ - ID: userID, - PrincipalName: "testuser@example.com", - IsDisabled: false, - EULAAccepted: true, - CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), - } - ) - - tests := []struct { - name string - expectations func(pool pgxmock.PgxPoolIface) - wantResult services.User - wantErr error - wantErrContains string - }{ - { - name: "returns the user on success", - expectations: func(pool pgxmock.PgxPoolIface) { - pool.ExpectQuery(expectedGetUserSQL).WithArgs(userID.String(), 1).WillReturnRows( - pool.NewRows(userRowColumns()).AddRow( - expected.ID.String(), - expected.PrincipalName, - expected.IsDisabled, - expected.EULAAccepted, - expected.CreatedAt, - expected.UpdatedAt, - ), - ) - }, - wantResult: expected, - }, - { - name: "maps CollectOneRow pgx.ErrNoRows to services.ErrNoUserFound", - expectations: func(pool pgxmock.PgxPoolIface) { - pool.ExpectQuery(expectedGetUserSQL).WithArgs(userID.String(), 1).WillReturnRows( - pool.NewRows(userRowColumns()), - ) - }, - wantErr: services.ErrNoUserFound, - }, - { - name: "propagates database errors", - expectations: func(pool pgxmock.PgxPoolIface) { - pool.ExpectQuery(expectedGetUserSQL).WithArgs(userID.String(), 1).WillReturnError(dbErr) - }, - wantErr: dbErr, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - store, pool := newTestStore(t) - tt.expectations(pool) - - result, err := store.GetUser(ctx, userID) - switch { - case tt.wantErr != nil: - assert.ErrorIs(t, err, tt.wantErr) - case tt.wantErrContains != "": - assert.ErrorContains(t, err, tt.wantErrContains) - default: - require.NoError(t, err) - assert.Equal(t, tt.wantResult, result) - } - require.NoError(t, pool.ExpectationsWereMet()) - }) - } -} diff --git a/server/identity/internal/handlers/mocks/identity.go b/server/identity/internal/handlers/mocks/identity.go index 960637f9759..75b5e000150 100644 --- a/server/identity/internal/handlers/mocks/identity.go +++ b/server/identity/internal/handlers/mocks/identity.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/identity/internal/services/mocks/database.go b/server/identity/internal/services/mocks/database.go index 2531c9e98a5..772ba22d751 100644 --- a/server/identity/internal/services/mocks/database.go +++ b/server/identity/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 @@ -7,7 +22,6 @@ package mocks import ( "context" - "github.com/gofrs/uuid" "github.com/specterops/bloodhound/server/identity/internal/services" mock "github.com/stretchr/testify/mock" ) @@ -170,69 +184,3 @@ func (_c *MockDatabase_GetRole_Call) RunAndReturn(run func(ctx context.Context, _c.Call.Return(run) return _c } - -// GetUser provides a mock function for the type MockDatabase -func (_mock *MockDatabase) GetUser(ctx context.Context, id uuid.UUID) (services.User, error) { - ret := _mock.Called(ctx, id) - - if len(ret) == 0 { - panic("no return value specified for GetUser") - } - - var r0 services.User - var r1 error - if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID) (services.User, error)); ok { - return returnFunc(ctx, id) - } - if returnFunc, ok := ret.Get(0).(func(context.Context, uuid.UUID) services.User); ok { - r0 = returnFunc(ctx, id) - } else { - r0 = ret.Get(0).(services.User) - } - if returnFunc, ok := ret.Get(1).(func(context.Context, uuid.UUID) error); ok { - r1 = returnFunc(ctx, id) - } else { - r1 = ret.Error(1) - } - return r0, r1 -} - -// MockDatabase_GetUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUser' -type MockDatabase_GetUser_Call struct { - *mock.Call -} - -// GetUser is a helper method to define mock.On call -// - ctx context.Context -// - id uuid.UUID -func (_e *MockDatabase_Expecter) GetUser(ctx interface{}, id interface{}) *MockDatabase_GetUser_Call { - return &MockDatabase_GetUser_Call{Call: _e.mock.On("GetUser", ctx, id)} -} - -func (_c *MockDatabase_GetUser_Call) Run(run func(ctx context.Context, id uuid.UUID)) *MockDatabase_GetUser_Call { - _c.Call.Run(func(args mock.Arguments) { - var arg0 context.Context - if args[0] != nil { - arg0 = args[0].(context.Context) - } - var arg1 uuid.UUID - if args[1] != nil { - arg1 = args[1].(uuid.UUID) - } - run( - arg0, - arg1, - ) - }) - return _c -} - -func (_c *MockDatabase_GetUser_Call) Return(user services.User, err error) *MockDatabase_GetUser_Call { - _c.Call.Return(user, err) - return _c -} - -func (_c *MockDatabase_GetUser_Call) RunAndReturn(run func(ctx context.Context, id uuid.UUID) (services.User, error)) *MockDatabase_GetUser_Call { - _c.Call.Return(run) - return _c -} diff --git a/server/identity/internal/services/services.go b/server/identity/internal/services/services.go index e01bdc021b7..272336b1a2f 100644 --- a/server/identity/internal/services/services.go +++ b/server/identity/internal/services/services.go @@ -23,8 +23,6 @@ import ( "database/sql" "errors" "time" - - "github.com/gofrs/uuid" ) type Permission struct { @@ -52,22 +50,8 @@ type Role struct { // ErrNoRoleFound indicates that no role with the given ID was found. var ErrNoRoleFound = errors.New("no role was found") -type User struct { - ID uuid.UUID `json:"id"` - PrincipalName string `json:"principal_name"` - IsDisabled bool `json:"is_disabled"` - EULAAccepted bool `json:"eula_accepted"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt sql.NullTime `json:"deleted_at"` -} - -// ErrNoUserFound indicates that no user with the given ID was found. -var ErrNoUserFound = errors.New("no user was found") - type Database interface { GetRole(ctx context.Context, id int32) (Role, error) - GetUser(ctx context.Context, id uuid.UUID) (User, error) GetPermission(ctx context.Context, id int) (Permission, error) } @@ -83,10 +67,6 @@ func (s *Service) GetRole(ctx context.Context, id int32) (Role, error) { return s.db.GetRole(ctx, id) } -func (s *Service) GetUser(ctx context.Context, id uuid.UUID) (User, error) { - return s.db.GetUser(ctx, id) -} - func (s *Service) GetPermission(ctx context.Context, id int) (Permission, error) { return s.db.GetPermission(ctx, id) } From 88aa61eaf34d937165019184a3428b25e967cb9e Mon Sep 17 00:00:00 2001 From: Stephanie Lamb Date: Tue, 30 Jun 2026 16:11:41 -0500 Subject: [PATCH 5/5] ran pfc --- AGENTS.md | 1 + cmd/api/src/api/registration/v2.go | 2 - cmd/api/src/api/v2/auth/auth.go | 31 --- cmd/api/src/api/v2/auth/auth_test.go | 239 ------------------------ server/identity/identity.go | 1 + server/identity/internal/appdb/appdb.go | 1 + 6 files changed, 3 insertions(+), 272 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d9c1bf2afd4..3a23e87fb9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ - The file located at `cmd/api/src/database/db.go` contains a Database interface type. This interface must be implemented by the `MockDatabase` struct in `cmd/api/src/database/mocks/db.go` and is generated by `go.uber.org/mock/mockgen`. - If the code adds a new API endpoint, or it changes something about an existing API endpoint (url, models that get marshaled to JSON, query params, etc), there should probably be changes to the OpenAPI yaml files. - If OpenAPI yaml files have been changed, `openapi.json` should also have corresponding changes. + - If a PR migrates an endpoint to the `bhce/server/` vertical-slice structure (or removes a legacy handler from `cmd/api/src`), the author SHOULD update the migration tracker in Notion. Reviewers should verify that the Notion status reflects the PR's scope. ## Database migration instructions diff --git a/cmd/api/src/api/registration/v2.go b/cmd/api/src/api/registration/v2.go index 5202367ba75..453dce59a35 100644 --- a/cmd/api/src/api/registration/v2.go +++ b/cmd/api/src/api/registration/v2.go @@ -82,11 +82,9 @@ func registerV2Auth(resources v2.Resources, routerInst *router.Router, permissio // Permissions routerInst.GET("/api/v2/permissions", managementResource.ListPermissions).RequirePermissions(permissions.AuthManageSelf), - routerInst.GET(fmt.Sprintf("/api/v2/permissions/{%s}", api.URIPathVariablePermissionID), managementResource.GetPermission).RequirePermissions(permissions.AuthManageSelf), // Roles routerInst.GET("/api/v2/roles", managementResource.ListRoles).RequirePermissions(permissions.AuthManageSelf), - routerInst.GET(fmt.Sprintf("/api/v2/roles/{%s}", api.URIPathVariableRoleID), managementResource.GetRole).RequirePermissions(permissions.AuthManageSelf), // User management for all BloodHound users routerInst.GET("/api/v2/bloodhound-users", managementResource.ListUsers).RequireAtLeastOnePermission(permissions.AuthManageUsers, permissions.AuthReadUsers), diff --git a/cmd/api/src/api/v2/auth/auth.go b/cmd/api/src/api/v2/auth/auth.go index 54917b20602..49f30acdc2d 100644 --- a/cmd/api/src/api/v2/auth/auth.go +++ b/cmd/api/src/api/v2/auth/auth.go @@ -22,7 +22,6 @@ import ( "log/slog" "net/http" "slices" - "strconv" "strings" "time" @@ -149,21 +148,6 @@ func (s ManagementResource) ListPermissions(response http.ResponseWriter, reques } } -func (s ManagementResource) GetPermission(response http.ResponseWriter, request *http.Request) { - var ( - pathVars = mux.Vars(request) - rawPermissionID = pathVars[api.URIPathVariablePermissionID] - ) - - if permissionID, err := strconv.Atoi(rawPermissionID); err != nil { - api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsIDMalformed, request), response) - } else if permission, err := s.db.GetPermission(request.Context(), permissionID); err != nil { - api.HandleDatabaseError(request, response, err) - } else { - api.WriteBasicResponse(request.Context(), permission, http.StatusOK, response) - } -} - func (s ManagementResource) ListRoles(response http.ResponseWriter, request *http.Request) { var ( order []string @@ -227,21 +211,6 @@ func (s ManagementResource) ListRoles(response http.ResponseWriter, request *htt } } -func (s ManagementResource) GetRole(response http.ResponseWriter, request *http.Request) { - var ( - pathVars = mux.Vars(request) - rawRoleID = pathVars[api.URIPathVariableRoleID] - ) - - if roleID, err := strconv.ParseInt(rawRoleID, 10, 32); err != nil { - api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsIDMalformed, request), response) - } else if role, err := s.db.GetRole(request.Context(), int32(roleID)); err != nil { - api.HandleDatabaseError(request, response, err) - } else { - api.WriteBasicResponse(request.Context(), role, http.StatusOK, response) - } -} - func (s ManagementResource) ListUsers(response http.ResponseWriter, request *http.Request) { var ( order []string diff --git a/cmd/api/src/api/v2/auth/auth_test.go b/cmd/api/src/api/v2/auth/auth_test.go index 6556050eec9..1f4cee73987 100644 --- a/cmd/api/src/api/v2/auth/auth_test.go +++ b/cmd/api/src/api/v2/auth/auth_test.go @@ -507,117 +507,6 @@ func TestManagementResource_ListPermissions(t *testing.T) { require.Equal(t, perm2.Authority, respPermissions["data"].(map[string]any)["permissions"].([]any)[1].(map[string]any)["authority"]) } } -func TestManagementResource_GetPermission(t *testing.T) { - t.Parallel() - - type mock struct { - mockDatabase *mocks.MockDatabase - } - type expected struct { - responseBody string - responseCode int - responseHeader http.Header - } - type testData struct { - name string - buildRequest func() *http.Request - setupMocks func(t *testing.T, mock *mock) - expected expected - } - - tt := []testData{ - { - name: "Error: Invalid permission ID format - Bad Request", - buildRequest: func() *http.Request { - return &http.Request{ - URL: &url.URL{ - Path: "/api/v2/permissions/invalid", - }, - Method: http.MethodGet, - } - }, - setupMocks: func(t *testing.T, mock *mock) {}, - expected: expected{ - responseCode: http.StatusBadRequest, - responseHeader: http.Header{"Content-Type": []string{"application/json"}}, - responseBody: `{"http_status":400,"timestamp":"0001-01-01T00:00:00Z","request_id":"","errors":[{"context":"","message":"id is malformed"}]}`, - }, - }, - { - name: "Error: Database Error GetPermission - Internal Server Error", - buildRequest: func() *http.Request { - return &http.Request{ - URL: &url.URL{ - Path: "/api/v2/permissions/123", - }, - Method: http.MethodGet, - } - }, - setupMocks: func(t *testing.T, mock *mock) { - mock.mockDatabase.EXPECT().GetPermission(gomock.Any(), 123).Return(model.Permission{}, errors.New("error")) - }, - expected: expected{ - responseCode: http.StatusInternalServerError, - responseHeader: http.Header{"Content-Type": []string{"application/json"}}, - responseBody: `{"http_status":500,"timestamp":"0001-01-01T00:00:00Z","request_id":"","errors":[{"context":"","message":"an internal error has occurred that is preventing the service from servicing this request"}]}`, - }, - }, - { - name: "Success: Permission found - OK", - buildRequest: func() *http.Request { - return &http.Request{ - URL: &url.URL{ - Path: "/api/v2/permissions/123", - }, - Method: http.MethodGet, - } - }, - setupMocks: func(t *testing.T, mock *mock) { - expectedPermission := model.Permission{ - Authority: "read:users", - Name: "Read Users", - Serial: model.Serial{ - ID: 123, - }, - } - mock.mockDatabase.EXPECT().GetPermission(gomock.Any(), 123).Return(expectedPermission, nil) - }, - expected: expected{ - responseCode: http.StatusOK, - responseHeader: http.Header{"Content-Type": []string{"application/json"}}, - responseBody: `{"data":{"authority":"read:users", "created_at":"0001-01-01T00:00:00Z", "deleted_at":{"Time":"0001-01-01T00:00:00Z", "Valid":false}, "id":123, "name":"Read Users", "updated_at":"0001-01-01T00:00:00Z"}}`, - }, - }, - } - - for _, testCase := range tt { - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - - mocks := &mock{ - mockDatabase: mocks.NewMockDatabase(ctrl), - } - - request := testCase.buildRequest() - testCase.setupMocks(t, mocks) - - response := httptest.NewRecorder() - - resources := auth.NewManagementResource(config.Configuration{}, mocks.mockDatabase, authz.NewAuthorizer(mocks.mockDatabase), api.NewAuthenticator(config.Configuration{}, mocks.mockDatabase, nil), nil, nil) - - router := mux.NewRouter() - router.HandleFunc(fmt.Sprintf("/api/v2/permissions/{%s}", api.URIPathVariablePermissionID), resources.GetPermission).Methods(request.Method) - router.ServeHTTP(response, request) - - status, header, body := test.ProcessResponse(t, response) - - assert.Equal(t, testCase.expected.responseCode, status) - assert.Equal(t, testCase.expected.responseHeader, header) - assert.JSONEq(t, testCase.expected.responseBody, body) - }) - } -} func TestManagementResource_ListRoles_SortingError(t *testing.T) { mockCtrl := gomock.NewController(t) @@ -846,134 +735,6 @@ func TestManagementResource_ListRoles_Filtered(t *testing.T) { } } -func TestManagementResource_GetRole(t *testing.T) { - t.Parallel() - - type mock struct { - mockDatabase *mocks.MockDatabase - } - type expected struct { - responseBody string - responseCode int - responseHeader http.Header - } - type testData struct { - name string - buildRequest func() *http.Request - setupMocks func(t *testing.T, mock *mock) - expected expected - } - - tt := []testData{ - { - name: "Error: Invalid role ID format - Bad Request", - buildRequest: func() *http.Request { - return &http.Request{ - URL: &url.URL{ - Path: "/api/v2/roles/invalid", - }, - Method: http.MethodGet, - } - }, - setupMocks: func(t *testing.T, mock *mock) {}, - expected: expected{ - responseCode: http.StatusBadRequest, - responseHeader: http.Header{"Content-Type": []string{"application/json"}}, - responseBody: `{"http_status":400,"timestamp":"0001-01-01T00:00:00Z","request_id":"","errors":[{"context":"","message":"id is malformed"}]}`, - }, - }, - { - name: "Error: Database Error GetRole - Internal Server Error", - buildRequest: func() *http.Request { - return &http.Request{ - URL: &url.URL{ - Path: "/api/v2/roles/123", - }, - Method: http.MethodGet, - } - }, - setupMocks: func(t *testing.T, mock *mock) { - mock.mockDatabase.EXPECT().GetRole(gomock.Any(), int32(123)).Return(model.Role{}, errors.New("error")) - }, - expected: expected{ - responseCode: http.StatusInternalServerError, - responseHeader: http.Header{"Content-Type": []string{"application/json"}}, - responseBody: `{"http_status":500,"timestamp":"0001-01-01T00:00:00Z","request_id":"","errors":[{"context":"","message":"an internal error has occurred that is preventing the service from servicing this request"}]}`, - }, - }, - { - name: "Success: Role found - OK", - buildRequest: func() *http.Request { - return &http.Request{ - URL: &url.URL{ - Path: "/api/v2/roles/123", - }, - Method: http.MethodGet, - } - }, - setupMocks: func(t *testing.T, mock *mock) { - permissionRead := model.Permission{ - Authority: "read:users", - Name: "Read Users", - Serial: model.Serial{ - ID: 1, - }, - } - permissionWrite := model.Permission{ - Authority: "write:users", - Name: "Write Users", - Serial: model.Serial{ - ID: 2, - }, - } - - expectedRole := model.Role{ - Name: "Administrator", - Description: "System administrator role", - Permissions: model.Permissions{permissionRead, permissionWrite}, - Serial: model.Serial{ - ID: 123, - }, - } - mock.mockDatabase.EXPECT().GetRole(gomock.Any(), int32(123)).Return(expectedRole, nil) - }, - expected: expected{ - responseCode: http.StatusOK, - responseHeader: http.Header{"Content-Type": []string{"application/json"}}, - responseBody: `{"data":{"created_at":"0001-01-01T00:00:00Z","deleted_at":{"Time":"0001-01-01T00:00:00Z","Valid":false},"description":"System administrator role","id":123,"name":"Administrator","permissions":[{"authority":"read:users","created_at":"0001-01-01T00:00:00Z","deleted_at":{"Time":"0001-01-01T00:00:00Z","Valid":false},"id":1,"name":"Read Users","updated_at":"0001-01-01T00:00:00Z"},{"authority":"write:users","created_at":"0001-01-01T00:00:00Z","deleted_at":{"Time":"0001-01-01T00:00:00Z","Valid":false},"id":2,"name":"Write Users","updated_at":"0001-01-01T00:00:00Z"}],"updated_at":"0001-01-01T00:00:00Z"}}`, - }, - }, - } - - for _, testCase := range tt { - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - ctrl := gomock.NewController(t) - - mocks := &mock{ - mockDatabase: mocks.NewMockDatabase(ctrl), - } - - request := testCase.buildRequest() - testCase.setupMocks(t, mocks) - - response := httptest.NewRecorder() - - resources := auth.NewManagementResource(config.Configuration{}, mocks.mockDatabase, authz.NewAuthorizer(mocks.mockDatabase), api.NewAuthenticator(config.Configuration{}, mocks.mockDatabase, nil), nil, nil) - - router := mux.NewRouter() - router.HandleFunc(fmt.Sprintf("/api/v2/roles/{%s}", api.URIPathVariableRoleID), resources.GetRole).Methods(request.Method) - router.ServeHTTP(response, request) - - status, header, body := test.ProcessResponse(t, response) - - assert.Equal(t, testCase.expected.responseCode, status) - assert.Equal(t, testCase.expected.responseHeader, header) - assert.JSONEq(t, testCase.expected.responseBody, body) - }) - } -} - func TestExpireUserAuthSecret_Failure(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() diff --git a/server/identity/identity.go b/server/identity/identity.go index 957527f1bc0..b34d974cba1 100644 --- a/server/identity/identity.go +++ b/server/identity/identity.go @@ -13,6 +13,7 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 + package identity import ( diff --git a/server/identity/internal/appdb/appdb.go b/server/identity/internal/appdb/appdb.go index 57eb0ecdfe1..ee417d91db7 100644 --- a/server/identity/internal/appdb/appdb.go +++ b/server/identity/internal/appdb/appdb.go @@ -13,6 +13,7 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 + package appdb import (