Skip to content

Commit 3220cda

Browse files
urangelAD7ZJ
andauthored
refactor: optimize az descendant queries BED-4867 (#2892)
* refactor: optimize az descendant queries BED-4867 * test: integration tests * chore: cleanup * fix: rabbit feedback for disconnected paths * Add test for nested management group * fix: nested management group descendents --------- Co-authored-by: Elijah Brown <ebrown@specterops.io>
1 parent dcc98e0 commit 3220cda

11 files changed

Lines changed: 465 additions & 79 deletions

File tree

cmd/api/src/api/v2/azure.go

Lines changed: 58 additions & 37 deletions
Large diffs are not rendered by default.

cmd/api/src/api/v2/azure_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) {
180180
},
181181
expected: expected{
182182
responseCode: http.StatusInternalServerError,
183-
responseBody: `{"errors":[{"context":"","message":"error fetching primary display kinds: database error"}],"http_status":500,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`,
183+
responseBody: `{"errors":[{"context":"","message":"an internal error has occurred that is preventing the service from servicing this request"}],"http_status":500,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`,
184184
responseHeader: http.Header{"Content-Type": []string{"application/json"}},
185185
},
186186
},

packages/go/analysis/azure/azure_integration_test.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,3 +949,222 @@ func TestEntityDetails(t *testing.T) {
949949
})
950950
})
951951
}
952+
953+
// TestFetchEntityDescendentPaths_DirectPathsToRoot verifies that each terminal node with a direct
954+
// azure.Contains edge to the root tenant is included in the returned path set.
955+
func TestFetchEntityDescendentPaths_DirectPathsToRoot(t *testing.T) {
956+
t.Parallel()
957+
958+
suite := setupIntegrationTestSuite(t)
959+
defer teardownIntegrationTestSuite(t, &suite)
960+
961+
var (
962+
tenantID = integration.RandomObjectID(t)
963+
tenantNode = NewAzureTenant(t, &suite, tenantID)
964+
app1 = NewAzureApplication(t, &suite, "App1", integration.RandomObjectID(t), tenantID)
965+
app2 = NewAzureApplication(t, &suite, "App2", integration.RandomObjectID(t), tenantID)
966+
)
967+
968+
NewRelationship(t, &suite, tenantNode, app1, graphAzure.Contains)
969+
NewRelationship(t, &suite, tenantNode, app2, graphAzure.Contains)
970+
971+
err := suite.GraphDB.ReadTransaction(suite.Context, func(tx graph.Transaction) error {
972+
paths, err := azure.FetchEntityDescendentPaths(tx, tenantNode, graphAzure.App)
973+
require.NoError(t, err)
974+
975+
nodeIDs := paths.AllNodes().IDs()
976+
require.Len(t, nodeIDs, 3)
977+
require.Contains(t, nodeIDs, tenantNode.ID)
978+
require.Contains(t, nodeIDs, app1.ID)
979+
require.Contains(t, nodeIDs, app2.ID)
980+
981+
return nil
982+
})
983+
require.NoError(t, err)
984+
}
985+
986+
// TestFetchEntityDescendentPaths_MultiHopPathToRoot verifies that a terminal node connected to the
987+
// root through one or more intermediate azure.Contains hops is fully represented in the path set,
988+
// with every intermediary node included.
989+
func TestFetchEntityDescendentPaths_MultiHopPathToRoot(t *testing.T) {
990+
t.Parallel()
991+
992+
suite := setupIntegrationTestSuite(t)
993+
defer teardownIntegrationTestSuite(t, &suite)
994+
995+
var (
996+
tenantID = integration.RandomObjectID(t)
997+
tenantNode = NewAzureTenant(t, &suite, tenantID)
998+
subscriptionNode = NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{
999+
common.Name: "TestSubscription",
1000+
common.ObjectID: integration.RandomObjectID(t),
1001+
graphAzure.TenantID: tenantID,
1002+
}), graphAzure.Entity, graphAzure.Subscription)
1003+
appNode = NewAzureApplication(t, &suite, "App", integration.RandomObjectID(t), tenantID)
1004+
)
1005+
1006+
// tenant --Contains--> subscription --Contains--> app
1007+
NewRelationship(t, &suite, tenantNode, subscriptionNode, graphAzure.Contains)
1008+
NewRelationship(t, &suite, subscriptionNode, appNode, graphAzure.Contains)
1009+
1010+
err := suite.GraphDB.ReadTransaction(suite.Context, func(tx graph.Transaction) error {
1011+
paths, err := azure.FetchEntityDescendentPaths(tx, tenantNode, graphAzure.App)
1012+
require.NoError(t, err)
1013+
1014+
nodeIDs := paths.AllNodes().IDs()
1015+
require.Len(t, nodeIDs, 3)
1016+
require.Contains(t, nodeIDs, tenantNode.ID)
1017+
require.Contains(t, nodeIDs, subscriptionNode.ID)
1018+
require.Contains(t, nodeIDs, appNode.ID)
1019+
1020+
return nil
1021+
})
1022+
require.NoError(t, err)
1023+
}
1024+
1025+
// TestFetchEntityDescendentPaths_NoTerminalsReturnsEmpty verifies that an empty path set is
1026+
// returned when no nodes of the requested kind exist within the tenant.
1027+
func TestFetchEntityDescendentPaths_NoTerminalsReturnsEmpty(t *testing.T) {
1028+
t.Parallel()
1029+
1030+
suite := setupIntegrationTestSuite(t)
1031+
defer teardownIntegrationTestSuite(t, &suite)
1032+
1033+
var (
1034+
tenantID = integration.RandomObjectID(t)
1035+
tenantNode = NewAzureTenant(t, &suite, tenantID)
1036+
)
1037+
1038+
err := suite.GraphDB.ReadTransaction(suite.Context, func(tx graph.Transaction) error {
1039+
paths, err := azure.FetchEntityDescendentPaths(tx, tenantNode, graphAzure.App)
1040+
require.NoError(t, err)
1041+
require.Equal(t, 0, paths.Len())
1042+
1043+
return nil
1044+
})
1045+
require.NoError(t, err)
1046+
}
1047+
1048+
// TestFetchEntityDescendentPaths_SharedIntermediateNode verifies that two terminal nodes sharing
1049+
// an intermediate container node both produce complete paths reaching root. Each traversal
1050+
// independently reaches root through the shared intermediate without being truncated.
1051+
func TestFetchEntityDescendentPaths_SharedIntermediateNode(t *testing.T) {
1052+
t.Parallel()
1053+
1054+
suite := setupIntegrationTestSuite(t)
1055+
defer teardownIntegrationTestSuite(t, &suite)
1056+
1057+
var (
1058+
tenantID = integration.RandomObjectID(t)
1059+
tenantNode = NewAzureTenant(t, &suite, tenantID)
1060+
subscriptionNode = NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{
1061+
common.Name: "SharedSubscription",
1062+
common.ObjectID: integration.RandomObjectID(t),
1063+
graphAzure.TenantID: tenantID,
1064+
}), graphAzure.Entity, graphAzure.Subscription)
1065+
app1 = NewAzureApplication(t, &suite, "App1", integration.RandomObjectID(t), tenantID)
1066+
app2 = NewAzureApplication(t, &suite, "App2", integration.RandomObjectID(t), tenantID)
1067+
)
1068+
1069+
// tenant --Contains--> subscription --Contains--> app1
1070+
// --Contains--> app2
1071+
NewRelationship(t, &suite, tenantNode, subscriptionNode, graphAzure.Contains)
1072+
NewRelationship(t, &suite, subscriptionNode, app1, graphAzure.Contains)
1073+
NewRelationship(t, &suite, subscriptionNode, app2, graphAzure.Contains)
1074+
1075+
err := suite.GraphDB.ReadTransaction(suite.Context, func(tx graph.Transaction) error {
1076+
paths, err := azure.FetchEntityDescendentPaths(tx, tenantNode, graphAzure.App)
1077+
require.NoError(t, err)
1078+
1079+
nodeIDs := paths.AllNodes().IDs()
1080+
require.Contains(t, nodeIDs, tenantNode.ID)
1081+
require.Contains(t, nodeIDs, subscriptionNode.ID)
1082+
require.Contains(t, nodeIDs, app1.ID)
1083+
require.Contains(t, nodeIDs, app2.ID)
1084+
1085+
return nil
1086+
})
1087+
require.NoError(t, err)
1088+
}
1089+
1090+
// TestFetchEntityDescendentPaths_TerminalNotConnectedToRoot verifies that a terminal node
1091+
// which exists in the same tenant but has no azure.Contains path leading back to root
1092+
// is excluded from the returned path set.
1093+
func TestFetchEntityDescendentPaths_TerminalNotConnectedToRoot(t *testing.T) {
1094+
t.Parallel()
1095+
1096+
suite := setupIntegrationTestSuite(t)
1097+
defer teardownIntegrationTestSuite(t, &suite)
1098+
1099+
var (
1100+
tenantID = integration.RandomObjectID(t)
1101+
tenantNode = NewAzureTenant(t, &suite, tenantID)
1102+
connectedApp = NewAzureApplication(t, &suite, "ConnectedApp", integration.RandomObjectID(t), tenantID)
1103+
disconnectedApp = NewAzureApplication(t, &suite, "DisconnectedApp", integration.RandomObjectID(t), tenantID)
1104+
)
1105+
1106+
// Only connectedApp has a Contains edge to root; disconnectedApp shares the tenantID
1107+
// but has no path leading back to tenantNode.
1108+
NewRelationship(t, &suite, tenantNode, connectedApp, graphAzure.Contains)
1109+
1110+
err := suite.GraphDB.ReadTransaction(suite.Context, func(tx graph.Transaction) error {
1111+
paths, err := azure.FetchEntityDescendentPaths(tx, tenantNode, graphAzure.App)
1112+
require.NoError(t, err)
1113+
1114+
nodeIDs := paths.AllNodes().IDs()
1115+
require.Contains(t, nodeIDs, tenantNode.ID)
1116+
require.Contains(t, nodeIDs, connectedApp.ID)
1117+
require.NotContains(t, nodeIDs, disconnectedApp.ID)
1118+
1119+
return nil
1120+
})
1121+
require.NoError(t, err)
1122+
}
1123+
1124+
// TestListEntityDescendents_NestedManagementGroupToSubscription verifies that a Subscription
1125+
// nested under one or more intermediate ManagementGroups is returned when listing the
1126+
// DescendentSubscriptions of an ancestor ManagementGroup.
1127+
func TestListEntityDescendents_NestedManagementGroupToSubscription(t *testing.T) {
1128+
t.Parallel()
1129+
1130+
suite := setupIntegrationTestSuite(t)
1131+
defer teardownIntegrationTestSuite(t, &suite)
1132+
1133+
var (
1134+
tenantID = integration.RandomObjectID(t)
1135+
mgRootObjectID = integration.RandomObjectID(t)
1136+
mgChildObjectID = integration.RandomObjectID(t)
1137+
subObjectID = integration.RandomObjectID(t)
1138+
tenantNode = NewAzureTenant(t, &suite, tenantID)
1139+
mgRootNode = NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{
1140+
common.Name: "MGRoot",
1141+
common.ObjectID: mgRootObjectID,
1142+
graphAzure.TenantID: tenantID,
1143+
}), graphAzure.Entity, graphAzure.ManagementGroup)
1144+
mgChildNode = NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{
1145+
common.Name: "MGChild",
1146+
common.ObjectID: mgChildObjectID,
1147+
graphAzure.TenantID: tenantID,
1148+
}), graphAzure.Entity, graphAzure.ManagementGroup)
1149+
nestedSubNode = NewNode(t, &suite, graph.AsProperties(graph.PropertyMap{
1150+
common.Name: "NestedSubscription",
1151+
common.ObjectID: subObjectID,
1152+
graphAzure.TenantID: tenantID,
1153+
}), graphAzure.Entity, graphAzure.Subscription)
1154+
)
1155+
1156+
// tenant --Contains--> mgRoot --Contains--> mgChild --Contains--> nestedSub
1157+
NewRelationship(t, &suite, tenantNode, mgRootNode, graphAzure.Contains)
1158+
NewRelationship(t, &suite, mgRootNode, mgChildNode, graphAzure.Contains)
1159+
NewRelationship(t, &suite, mgChildNode, nestedSubNode, graphAzure.Contains)
1160+
1161+
nodes, err := azure.ListEntityDescendents(
1162+
suite.Context, suite.GraphDB,
1163+
azure.RelatedEntityTypeDescendentSubscriptions, graphAzure.ManagementGroup,
1164+
mgRootObjectID, 0, 0,
1165+
)
1166+
require.NoError(t, err)
1167+
1168+
require.Equal(t, 1, len(nodes), "expected nested subscription to be returned as a descendent of mgRoot")
1169+
require.Contains(t, nodes.IDs(), nestedSubNode.ID)
1170+
}

packages/go/analysis/azure/db_ops.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import (
2525
"github.com/specterops/dawgs/ops"
2626
)
2727

28-
func ListEntityDescendentPaths(ctx context.Context, db graph.Database, relatedEntityType RelatedEntityType, objectID string) (graph.PathSet, error) {
28+
func ListEntityDescendentPaths(ctx context.Context, db graph.Database, relatedEntityType RelatedEntityType, sourceKind graph.Kind, objectID string) (graph.PathSet, error) {
2929
var paths graph.PathSet
3030

3131
return paths, db.ReadTransaction(ctx, func(tx graph.Transaction) error {
@@ -73,13 +73,17 @@ func ListEntityDescendentPaths(ctx context.Context, db graph.Database, relatedEn
7373
return ErrInvalidRelatedEntityType
7474
}
7575

76-
paths, err = FetchEntityDescendentPaths(tx, node, targetKind)
76+
if isDirectDescendent(sourceKind, targetKind) {
77+
paths, err = FetchDirectDescendentPaths(tx, node, targetKind)
78+
} else {
79+
paths, err = FetchEntityDescendentPaths(tx, node, targetKind)
80+
}
7781
return err
7882
}
7983
})
8084
}
8185

82-
func ListEntityDescendents(ctx context.Context, db graph.Database, relatedEntityType RelatedEntityType, objectID string, skip, limit int) (graph.NodeSet, error) {
86+
func ListEntityDescendents(ctx context.Context, db graph.Database, relatedEntityType RelatedEntityType, sourceKind graph.Kind, objectID string, skip, limit int) (graph.NodeSet, error) {
8387
var nodes graph.NodeSet
8488

8589
return nodes, db.ReadTransaction(ctx, func(tx graph.Transaction) error {
@@ -127,7 +131,11 @@ func ListEntityDescendents(ctx context.Context, db graph.Database, relatedEntity
127131
return ErrInvalidRelatedEntityType
128132
}
129133

130-
nodes, err = FetchEntityDescendents(tx, node, skip, limit, targetKind)
134+
if isDirectDescendent(sourceKind, targetKind) {
135+
nodes, err = FetchDirectEntityDescendents(tx, node, targetKind)
136+
} else {
137+
nodes, err = FetchEntityDescendents(tx, node, targetKind)
138+
}
131139
return err
132140
}
133141
})

packages/go/analysis/azure/management_group.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func ManagementGroupEntityDetails(ctx context.Context, db graph.Database, primar
4545
func PopulateManagementGroupEntityDetailsCounts(tx graph.Transaction, node *graph.Node, details ManagementGroupDetails) (ManagementGroupDetails, error) {
4646
var descendentKinds = GetDescendentKinds(azure.ManagementGroup)
4747

48-
if descendents, err := FetchEntityDescendentCounts(tx, node, 0, 0, descendentKinds...); err != nil {
48+
if descendents, err := FetchEntityDescendentCounts(tx, node, descendentKinds...); err != nil {
4949
return details, err
5050
} else {
5151
details.Descendents = descendents

0 commit comments

Comments
 (0)