Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 0 additions & 2 deletions cmd/api/src/api/registration/v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
31 changes: 0 additions & 31 deletions cmd/api/src/api/v2/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import (
"log/slog"
"net/http"
"slices"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
239 changes: 0 additions & 239 deletions cmd/api/src/api/v2/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
39 changes: 39 additions & 0 deletions server/identity/identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// 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 (
"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)
}
Loading
Loading