Skip to content

Commit 89a5da2

Browse files
committed
Merge branch 'main' into BED-8246--full-path-highlight
2 parents d1e77ee + 791ff65 commit 89a5da2

100 files changed

Lines changed: 3618 additions & 1929 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@
3636
## Database migration instructions
3737

3838
- Migration files must use goose annotations: `-- +goose Up` and `-- +goose Down`
39-
- Migration filenames must follow the pattern: `YYYYMMDDHHMMSS_descriptive_name.sql`
39+
- Migration filenames must follow the pattern: `<timestamp>_<version>_<name>.sql`
40+
- Timestamp: `YYYYMMDDHHMMSS`
41+
- Version: `v9`, `v10`, etc. (current major release)
42+
- Name: descriptive snake_case name
43+
- Example: `20260514120000_v9_add_feature_flags.sql`
4044
- Never modify baseline migrations (`00000000000001_init.sql` or `00000000000002_init.sql`)
4145
- Never modify migrations that have already been merged to main. The user should create a new migration instead
4246
- Migration locations:

architecture/model/api-server.c4

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ model {
111111
technology 'Go'
112112
}
113113

114+
// ─── Feature Flags ─────────────────────────────────────────
115+
116+
featureFlagClient = component 'Feature Flag Client' {
117+
#go #featureFlags
118+
description 'OpenFeature SDK client used by application code to evaluate feature flags. The active provider is selected at build time, decoupling flag evaluation call sites from the backend. In BHCE the from-env provider reads flag defaults from environment variables; BHE swaps in LaunchDarkly (Fleet) or a Replicated-license-backed InMemoryProvider (On-Prem).'
119+
technology 'Go / OpenFeature SDK'
120+
}
121+
114122
// ─── Data Access ───────────────────────────────────────────
115123

116124
databaseClient = library 'Database Client' {

architecture/model/relationships.c4

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,5 +109,9 @@ model {
109109
bloodhound.apiServer.databaseClient -[sync]-> bloodhound.postgresql 'SQL queries'
110110
bloodhound.apiServer.graphClient -[sync]-> bloodhound.postgresqlGraph 'Graph operations (DAWGS)'
111111
bloodhound.apiServer.graphClient -[sync]-> bloodhound.neo4j 'Cypher queries'
112+
113+
// Feature flag evaluation
114+
bloodhound.apiServer.restApi -[uses]-> bloodhound.apiServer.featureFlagClient 'Evaluates feature flags'
115+
bloodhound.apiServer.analysisService -[uses]-> bloodhound.apiServer.featureFlagClient 'Evaluates feature flags'
112116
}
113117

architecture/specification.c4

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,5 +101,6 @@ specification {
101101
tag react
102102
tag graphdb
103103
tag relationaldb
104+
tag featureFlags
104105
}
105106

architecture/views/api-server.c4

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ views {
2626
notation "Data access layer"
2727
color muted
2828
}
29+
30+
style bloodhound.apiServer.featureFlagClient {
31+
notation "Feature flag client"
32+
color slate
33+
}
2934
}
3035
}
3136

cmd/api/src/api/v2/assetgrouptags_test.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,10 @@ import (
6161
)
6262

6363
func TestResources_GetAssetGroupTags(t *testing.T) {
64-
const queryParamTagType = "type"
64+
const (
65+
queryParamTagType = "type"
66+
queryParamName = "name"
67+
)
6568
var (
6669
mockCtrl = gomock.NewController(t)
6770
mockDB = mocks_db.NewMockDatabase(mockCtrl)
@@ -209,6 +212,50 @@ func TestResources_GetAssetGroupTags(t *testing.T) {
209212
apitest.Equal(output, 2, tierCount)
210213
},
211214
},
215+
{
216+
Name: "NumericStringName",
217+
Input: func(input *apitest.Input) {
218+
apitest.AddQueryParam(input, queryParamName, "eq:123")
219+
},
220+
Setup: func() {
221+
mockDB.EXPECT().
222+
GetAssetGroupTags(gomock.Any(), model.SQLFilter{
223+
SQLString: queryParamName + " = '123'",
224+
}).
225+
Return(model.AssetGroupTags{
226+
model.AssetGroupTag{ID: 1, Name: "123"},
227+
}, nil)
228+
},
229+
Test: func(output apitest.Output) {
230+
resp := v2.GetAssetGroupTagsResponse{}
231+
apitest.StatusCode(output, http.StatusOK)
232+
apitest.UnmarshalData(output, &resp)
233+
require.Len(t, resp.Tags, 1)
234+
apitest.Equal(output, "123", resp.Tags[0].Name)
235+
},
236+
},
237+
{
238+
Name: "Bool-likeName",
239+
Input: func(input *apitest.Input) {
240+
apitest.AddQueryParam(input, queryParamName, "eq:t")
241+
},
242+
Setup: func() {
243+
mockDB.EXPECT().
244+
GetAssetGroupTags(gomock.Any(), model.SQLFilter{
245+
SQLString: queryParamName + " = 't'",
246+
}).
247+
Return(model.AssetGroupTags{
248+
model.AssetGroupTag{ID: 1, Name: "t"},
249+
}, nil)
250+
},
251+
Test: func(output apitest.Output) {
252+
resp := v2.GetAssetGroupTagsResponse{}
253+
apitest.StatusCode(output, http.StatusOK)
254+
apitest.UnmarshalData(output, &resp)
255+
require.Len(t, resp.Tags, 1)
256+
apitest.Equal(output, "t", resp.Tags[0].Name)
257+
},
258+
},
212259
{
213260
Name: "Check Selector counts",
214261
Input: func(input *apitest.Input) {

cmd/api/src/api/v2/auth/auth.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ func (s ManagementResource) CreateUser(response http.ResponseWriter, request *ht
390390
// The migration sets the default for all_environments to true, which will enable all users to have access to all environments until ETAC is explicitly enabled
391391
userTemplate.AllEnvironments = false
392392

393-
if err := handleETACRequest(request.Context(), createUserRequest.UpdateUserRequest, roles, &userTemplate, s.GraphQuery); err != nil {
393+
if err := handleETACRequest(createUserRequest.UpdateUserRequest, roles, &userTemplate, s.GraphQuery); err != nil {
394394
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response)
395395
return
396396
}
@@ -526,7 +526,7 @@ func (s ManagementResource) UpdateUser(response http.ResponseWriter, request *ht
526526
effectiveRoles = roles
527527
}
528528

529-
if err := handleETACRequest(request.Context(), updateUserRequest, effectiveRoles, &user, s.GraphQuery); err != nil {
529+
if err := handleETACRequest(updateUserRequest, effectiveRoles, &user, s.GraphQuery); err != nil {
530530
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response)
531531
return
532532
}

cmd/api/src/api/v2/auth/auth_test.go

Lines changed: 11 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,6 @@ import (
5858
"github.com/specterops/bloodhound/cmd/api/src/utils/test"
5959
"github.com/specterops/bloodhound/cmd/api/src/utils/validation"
6060
"github.com/specterops/bloodhound/packages/go/bhlog"
61-
"github.com/specterops/bloodhound/packages/go/graphschema/ad"
62-
"github.com/specterops/bloodhound/packages/go/graphschema/azure"
6361
"github.com/specterops/bloodhound/packages/go/graphschema/common"
6462
"github.com/specterops/bloodhound/packages/go/headers"
6563
"github.com/specterops/bloodhound/packages/go/mediatypes"
@@ -1586,17 +1584,9 @@ func TestCreateUser_ETAC(t *testing.T) {
15861584
},
15871585
},
15881586
expectMocks: func(mockDB *mocks.MockDatabase, goodUser model.User, mockGraphDB *mocks_graph.MockGraph) {
1589-
mockGraphDB.EXPECT().FetchNodesByObjectIDsAndKinds(gomock.Any(), graph.Kinds{ad.Domain, azure.Tenant}, []string{"12345", "54321"}).Return(graph.NodeSet{
1590-
graph.ID(1): {
1591-
Properties: graph.AsProperties(map[string]any{
1592-
common.ObjectID.String(): "12345",
1593-
}),
1594-
},
1595-
graph.ID(2): {
1596-
Properties: graph.AsProperties(map[string]any{
1597-
common.ObjectID.String(): "54321",
1598-
}),
1599-
},
1587+
mockGraphDB.EXPECT().GetFilteredAndSortedNodes(gomock.Any(), gomock.Any()).Return([]*graph.Node{
1588+
{Properties: graph.AsProperties(map[string]any{common.ObjectID.String(): "12345"})},
1589+
{Properties: graph.AsProperties(map[string]any{common.ObjectID.String(): "54321"})},
16001590
}, nil)
16011591
mockDB.EXPECT().CreateUser(gomock.Any(), gomock.Any()).Return(goodUser, nil).AnyTimes()
16021592
},
@@ -1732,17 +1722,13 @@ func TestCreateUser_ETAC(t *testing.T) {
17321722
},
17331723
},
17341724
expectMocks: func(mockDB *mocks.MockDatabase, goodUser model.User, mockGraphDB *mocks_graph.MockGraph) {
1735-
mockGraphDB.EXPECT().FetchNodesByObjectIDsAndKinds(gomock.Any(), graph.Kinds{ad.Domain, azure.Tenant}, []string{"12345", "54321"}).Return(graph.NodeSet{
1736-
graph.ID(1): {
1737-
Properties: graph.AsProperties(map[string]any{
1738-
common.ObjectID.String(): "12345",
1739-
}),
1740-
},
1725+
mockGraphDB.EXPECT().GetFilteredAndSortedNodes(gomock.Any(), gomock.Any()).Return([]*graph.Node{
1726+
{Properties: graph.AsProperties(map[string]any{common.ObjectID.String(): "12345"})},
17411727
}, nil)
17421728
},
17431729
expectedStatus: http.StatusBadRequest,
17441730
assertBody: func(t *testing.T, body string) {
1745-
assert.Contains(t, body, "domain or tenant not found: 54321")
1731+
assert.Contains(t, body, "environment not found: 54321")
17461732
},
17471733
},
17481734
}
@@ -2995,12 +2981,8 @@ func TestManagementResource_UpdateUser_ETAC(t *testing.T) {
29952981
expectedStatus: http.StatusOK,
29962982
assertBody: func(t *testing.T, _ string) {},
29972983
expectMocks: func(mockDB *mocks.MockDatabase, goodUser model.User, mockGraphDB *mocks_graph.MockGraph) {
2998-
mockGraphDB.EXPECT().FetchNodesByObjectIDsAndKinds(gomock.Any(), graph.Kinds{ad.Domain, azure.Tenant}, []string{"12345"}).Return(graph.NodeSet{
2999-
graph.ID(1): {
3000-
Properties: graph.AsProperties(map[string]any{
3001-
common.ObjectID.String(): "12345",
3002-
}),
3003-
},
2984+
mockGraphDB.EXPECT().GetFilteredAndSortedNodes(gomock.Any(), gomock.Any()).Return([]*graph.Node{
2985+
{Properties: graph.AsProperties(map[string]any{common.ObjectID.String(): "12345"})},
30042986
}, nil)
30052987
mockDB.EXPECT().UpdateUser(gomock.Any(), gomock.Any()).Return(nil)
30062988
},
@@ -3086,15 +3068,11 @@ func TestManagementResource_UpdateUser_ETAC(t *testing.T) {
30863068
},
30873069
expectedStatus: http.StatusBadRequest,
30883070
assertBody: func(t *testing.T, body string) {
3089-
assert.Contains(t, body, "domain or tenant not found: 54321")
3071+
assert.Contains(t, body, "environment not found: 54321")
30903072
},
30913073
expectMocks: func(mockDB *mocks.MockDatabase, goodUser model.User, mockGraphDB *mocks_graph.MockGraph) {
3092-
mockGraphDB.EXPECT().FetchNodesByObjectIDsAndKinds(gomock.Any(), graph.Kinds{ad.Domain, azure.Tenant}, []string{"12345", "54321"}).Return(graph.NodeSet{
3093-
graph.ID(1): {
3094-
Properties: graph.AsProperties(map[string]any{
3095-
common.ObjectID.String(): "12345",
3096-
}),
3097-
},
3074+
mockGraphDB.EXPECT().GetFilteredAndSortedNodes(gomock.Any(), gomock.Any()).Return([]*graph.Node{
3075+
{Properties: graph.AsProperties(map[string]any{common.ObjectID.String(): "12345"})},
30983076
}, nil)
30993077
},
31003078
},

cmd/api/src/api/v2/auth/etac.go

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
package auth
1818

1919
import (
20-
"context"
2120
"errors"
2221
"fmt"
2322

@@ -26,17 +25,18 @@ import (
2625
"github.com/specterops/bloodhound/cmd/api/src/auth"
2726
"github.com/specterops/bloodhound/cmd/api/src/model"
2827
"github.com/specterops/bloodhound/cmd/api/src/queries"
28+
"github.com/specterops/bloodhound/packages/go/graphschema"
2929
"github.com/specterops/bloodhound/packages/go/graphschema/ad"
3030
"github.com/specterops/bloodhound/packages/go/graphschema/azure"
3131
"github.com/specterops/bloodhound/packages/go/graphschema/common"
32-
"github.com/specterops/dawgs/graph"
32+
"github.com/specterops/dawgs/query"
3333
)
3434

3535
// handleETACRequest will modify the user passed in to assign an etac list or grant all environment access
3636
// and will return an error on bad requests
3737
// Administrators and Power Users may not have an ETAC list applied to them
3838
// The user may not request all environments and have an ETAC list applied to them
39-
func handleETACRequest(ctx context.Context, updateUserRequest v2.UpdateUserRequest, roles model.Roles, user *model.User, graphDB queries.Graph) error {
39+
func handleETACRequest(updateUserRequest v2.UpdateUserRequest, roles model.Roles, user *model.User, graphDB queries.Graph) error {
4040
if updateUserRequest.AllEnvironments.Valid || updateUserRequest.EnvironmentTargetedAccessControl != nil {
4141
// Admin / Power Users can only have all_environments set to true
4242
if (!hasValidRolesForETAC(roles)) &&
@@ -70,17 +70,27 @@ func handleETACRequest(ctx context.Context, updateUserRequest v2.UpdateUserReque
7070
envIds = append(envIds, environment.EnvironmentID)
7171
}
7272

73-
if nodes, err := graphDB.FetchNodesByObjectIDsAndKinds(ctx, graph.Kinds{
74-
ad.Domain, azure.Tenant,
75-
}, envIds...); err != nil {
73+
if nodes, err := graphDB.GetFilteredAndSortedNodes(query.SortItems{},
74+
query.And(
75+
query.In(query.NodeProperty(common.ObjectID.String()), envIds),
76+
query.Or(
77+
query.In(query.NodeProperty(ad.DomainSID.String()), envIds),
78+
query.In(query.NodeProperty(azure.TenantID.String()), envIds),
79+
query.In(query.NodeProperty(graphschema.EnvironmentIDKey), envIds),
80+
),
81+
)); err != nil {
7682
return fmt.Errorf("error fetching environments: %w", err)
77-
} else if nodesByObject, err := nodeSetToObjectIDMap(nodes); err != nil {
78-
return err
7983
} else {
84+
var validNodes = make(map[string]bool)
85+
for _, node := range nodes {
86+
if objectID, err := node.Properties.Get(common.ObjectID.String()).String(); err == nil {
87+
validNodes[objectID] = true
88+
}
89+
}
8090
user.EnvironmentTargetedAccessControl = make([]model.EnvironmentTargetedAccessControl, 0, len(envIds))
8191
for _, envId := range envIds {
82-
if _, ok := nodesByObject[envId]; !ok {
83-
return fmt.Errorf("domain or tenant not found: %s", envId)
92+
if !validNodes[envId] {
93+
return fmt.Errorf("environment not found: %s", envId)
8494
} else {
8595
user.EnvironmentTargetedAccessControl = append(user.EnvironmentTargetedAccessControl, model.EnvironmentTargetedAccessControl{
8696
UserID: user.ID.String(),
@@ -93,22 +103,6 @@ func handleETACRequest(ctx context.Context, updateUserRequest v2.UpdateUserReque
93103
return nil
94104
}
95105

96-
func nodeSetToObjectIDMap(set graph.NodeSet) (map[string]bool, error) {
97-
var (
98-
objectIDs = make(map[string]bool)
99-
)
100-
101-
for _, node := range set {
102-
if objectID, err := node.Properties.Get(common.ObjectID.String()).String(); err != nil {
103-
return objectIDs, err
104-
} else {
105-
objectIDs[objectID] = true
106-
}
107-
}
108-
109-
return objectIDs, nil
110-
}
111-
112106
// hasValidRolesForETAC will check the passed in roles to determine if a user can have ETAC controls applied to theem
113107
// returning true if they may be ETAC controlled and false if they may not
114108
func hasValidRolesForETAC(roles model.Roles) bool {

cmd/api/src/api/v2/cypherquery.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,32 @@ func (s Resources) CypherQuery(response http.ResponseWriter, request *http.Reque
104104
return
105105
}
106106

107+
auditLogEntry, err := model.NewAuditEntry(
108+
model.AuditLogActionRunCypherQuery,
109+
model.AuditLogStatusIntent,
110+
model.AuditData{
111+
"query": preparedQuery.StrippedQuery,
112+
"include_properties": payload.IncludeProperties,
113+
},
114+
)
115+
if err != nil {
116+
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response)
117+
return
118+
}
119+
120+
if err = s.DB.AppendAuditLog(request.Context(), auditLogEntry); err != nil {
121+
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, err.Error(), request), response)
122+
return
123+
}
124+
125+
auditLogEntry.Status = model.AuditLogStatusFailure
126+
127+
defer func() {
128+
if err = s.DB.AppendAuditLog(request.Context(), auditLogEntry); err != nil {
129+
slog.ErrorContext(request.Context(), "Failure to create run cypher query audit log", attr.Error(err))
130+
}
131+
}()
132+
107133
primaryDisplayKinds, err := s.DB.GetPrimaryDisplayKinds(request.Context())
108134
if err != nil {
109135
api.HandleDatabaseError(request, response, err)
@@ -138,6 +164,8 @@ func (s Resources) CypherQuery(response http.ResponseWriter, request *http.Reque
138164
return
139165
}
140166

167+
auditLogEntry.Status = model.AuditLogStatusSuccess
168+
141169
if !payload.IncludeProperties {
142170
// removing node properties from the response
143171
for id, node := range graphResponse.Nodes {

0 commit comments

Comments
 (0)