From 7c5a167ecaaaf956f748760c6438c7f84fa1dd51 Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Thu, 19 Feb 2026 12:48:28 -0600 Subject: [PATCH 01/11] BED-7403 added ETAC to entity APIs --- cmd/api/src/api/v2/ad_entity.go | 14 ++++++++++++++ cmd/api/src/api/v2/ad_related_entity.go | 14 ++++++++++++++ cmd/api/src/api/v2/azure.go | 16 +++++++++++++++- cmd/api/src/api/v2/etac.go | 19 +++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/cmd/api/src/api/v2/ad_entity.go b/cmd/api/src/api/v2/ad_entity.go index 79882117ca8..406be45abd3 100644 --- a/cmd/api/src/api/v2/ad_entity.go +++ b/cmd/api/src/api/v2/ad_entity.go @@ -18,9 +18,12 @@ package v2 import ( "fmt" + "log/slog" "net/http" "github.com/specterops/bloodhound/cmd/api/src/api" + "github.com/specterops/bloodhound/cmd/api/src/auth" + bhCtx "github.com/specterops/bloodhound/cmd/api/src/ctx" adAnalysis "github.com/specterops/bloodhound/packages/go/analysis/ad" "github.com/specterops/bloodhound/packages/go/analysis/tiering" "github.com/specterops/bloodhound/packages/go/graphschema/ad" @@ -64,10 +67,21 @@ func (s *Resources) PatchDomain(response http.ResponseWriter, request *http.Requ } func (s *Resources) handleAdEntityInfoQuery(response http.ResponseWriter, request *http.Request, entityType graph.Kind, countQueries map[string]any) { + user, isUser := auth.GetUserFromAuthCtx(bhCtx.FromRequest(request).AuthCtx) + if !isUser { + slog.Error("Unable to get user from auth context") + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) + return + } + if includeCounts, err := api.ParseOptionalBool(request.URL.Query().Get(api.QueryParameterIncludeCounts), true); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsBadQueryParameterFilters, request), response) } else if objectId, err := GetEntityObjectIDFromRequestPath(request); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf("error reading objectid: %v", err), request), response) + } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, objectId, entityType); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) + } else if !hasAccess { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) } else if node, err := s.GraphQuery.GetEntityByObjectId(request.Context(), objectId, entityType); err != nil { if graph.IsErrNotFound(err) { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusNotFound, "node not found", request), response) diff --git a/cmd/api/src/api/v2/ad_related_entity.go b/cmd/api/src/api/v2/ad_related_entity.go index 55cc4533d52..68da0ed51f5 100644 --- a/cmd/api/src/api/v2/ad_related_entity.go +++ b/cmd/api/src/api/v2/ad_related_entity.go @@ -19,9 +19,12 @@ package v2 import ( "errors" "fmt" + "log/slog" "net/http" "github.com/specterops/bloodhound/cmd/api/src/api" + "github.com/specterops/bloodhound/cmd/api/src/auth" + bhCtx "github.com/specterops/bloodhound/cmd/api/src/ctx" "github.com/specterops/bloodhound/cmd/api/src/model" "github.com/specterops/bloodhound/cmd/api/src/model/appcfg" "github.com/specterops/bloodhound/cmd/api/src/queries" @@ -37,8 +40,19 @@ import ( // Path delegates are for graphing, list delegates are for listing and counting. Endpoints // without a certain delegate do not support that delegate feature. func (s *Resources) handleAdRelatedEntityQuery(response http.ResponseWriter, request *http.Request, queryName string, pathDelegate any, listDelegate any) { + user, isUser := auth.GetUserFromAuthCtx(bhCtx.FromRequest(request).AuthCtx) + if !isUser { + slog.Error("Unable to get user from auth context") + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) + return + } + if params, err := queries.BuildEntityQueryParams(request, queryName, pathDelegate, listDelegate); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf(api.FmtErrorResponseDetailsBadQueryParameters, err), request), response) + } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, params.ObjectID, ad.Entity); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) + } else if !hasAccess { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) } else if entityPanelCachingFlag, err := s.DB.GetFlagByKey(request.Context(), appcfg.FeatureEntityPanelCaching); err != nil { api.HandleDatabaseError(request, response, err) } else if results, count, err := s.GraphQuery.GetADEntityQueryResult(request.Context(), params, entityPanelCachingFlag.Enabled); err != nil { diff --git a/cmd/api/src/api/v2/azure.go b/cmd/api/src/api/v2/azure.go index 7913ff7acd5..715f0a5ea4c 100644 --- a/cmd/api/src/api/v2/azure.go +++ b/cmd/api/src/api/v2/azure.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" "sort" @@ -27,6 +28,8 @@ import ( azure2 "github.com/specterops/bloodhound/cmd/api/src/analysis/azure" "github.com/specterops/bloodhound/cmd/api/src/api" "github.com/specterops/bloodhound/cmd/api/src/api/bloodhoundgraph" + "github.com/specterops/bloodhound/cmd/api/src/auth" + bhCtx "github.com/specterops/bloodhound/cmd/api/src/ctx" "github.com/specterops/bloodhound/cmd/api/src/model" "github.com/specterops/bloodhound/cmd/api/src/utils" "github.com/specterops/bloodhound/packages/go/analysis/azure" @@ -199,7 +202,7 @@ func listRelatedEntityType(ctx context.Context, db graph.Database, entityType, o nodeSet graph.NodeSet err error ) - //NOTE: All skip/limit passed to lower level queries is currently hardcoded to 0 so we can get the full count of the dataset for skip/limit tracking + // NOTE: All skip/limit passed to lower level queries is currently hardcoded to 0 so we can get the full count of the dataset for skip/limit tracking switch relatedEntityType := azure.RelatedEntityType(entityType); relatedEntityType { case azure.RelatedEntityTypeDescendentUsers, azure.RelatedEntityTypeDescendentGroups, azure.RelatedEntityTypeDescendentManagementGroups, azure.RelatedEntityTypeDescendentSubscriptions, @@ -411,12 +414,23 @@ func (s *Resources) GetAZEntity(response http.ResponseWriter, request *http.Requ entityType = requestVars[entityTypePathParameterName] ) + user, isUser := auth.GetUserFromAuthCtx(bhCtx.FromRequest(request).AuthCtx) + if !isUser { + slog.Error("Unable to get user from auth context") + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) + return + } + if objectID := queryVars.Get(objectIDQueryParameterName); objectID == "" { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf("query parameter %s is required", objectIDQueryParameterName), request), response) } else if relatedEntityTypeStr := queryVars.Get(relatedEntityTypeQueryParameterName); relatedEntityTypeStr != "" { s.GetAZRelatedEntities(request.Context(), response, request, objectID) } else if includeCounts, err := api.ParseOptionalBool(queryVars.Get(api.QueryParameterIncludeCounts), true); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsBadQueryParameterFilters, request), response) + } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, objectID, graph.StringKind(entityType)); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) + } else if !hasAccess { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) } else if entityInformation, err := GetAZEntityInformation(request.Context(), s.Graph, entityType, objectID, includeCounts); err != nil { if graph.IsErrNotFound(err) { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusNotFound, "not found", request), response) diff --git a/cmd/api/src/api/v2/etac.go b/cmd/api/src/api/v2/etac.go index 34ed4cbaef0..47edf5bc087 100644 --- a/cmd/api/src/api/v2/etac.go +++ b/cmd/api/src/api/v2/etac.go @@ -24,7 +24,9 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/database" "github.com/specterops/bloodhound/cmd/api/src/model" + "github.com/specterops/bloodhound/cmd/api/src/queries" "github.com/specterops/bloodhound/cmd/api/src/services/dogtags" + "github.com/specterops/bloodhound/packages/go/graphschema/ad" "github.com/specterops/dawgs/graph" ) @@ -63,6 +65,23 @@ func CheckUserAccessToEnvironments(ctx context.Context, db database.EnvironmentT return true, nil } +// CheckUserHasAccessToNodeById returns whether a user has access to view this node based on their ETAC list +func CheckUserHasAccessToNodeById(ctx context.Context, db database.Database, graphQuery queries.Graph, dogTagsService dogtags.Service, user model.User, objectId string, kind graph.Kind) (bool, error) { + if etacEnabled := dogTagsService.GetFlagAsBool(dogtags.ETAC_ENABLED); etacEnabled { + node, err := graphQuery.GetEntityByObjectId(ctx, objectId, kind) + if err != nil || node == nil { + } else if domainSid, err := node.Properties.Get(ad.DomainSID.String()).String(); err != nil { + return false, err + } else if hasAccess, err := CheckUserAccessToEnvironments(ctx, db, user, domainSid); err != nil { + return false, err + } else if !hasAccess { + return false, nil + } + } + + return true, nil +} + // ExtractEnvironmentIDsFromUser is a helper function // to extract a user's environments from their model as a list of strings func ExtractEnvironmentIDsFromUser(user *model.User) []string { From 4a627107859abebd834593d8b30aaad3304afd0d Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Thu, 19 Feb 2026 15:03:59 -0600 Subject: [PATCH 02/11] BED-7403 existing tests pass --- cmd/api/src/api/v2/ad_entity_test.go | 299 +++++++++++++++---- cmd/api/src/api/v2/ad_related_entity_test.go | 55 +++- cmd/api/src/api/v2/azure_test.go | 56 +++- cmd/api/src/api/v2/etac.go | 23 +- 4 files changed, 351 insertions(+), 82 deletions(-) diff --git a/cmd/api/src/api/v2/ad_entity_test.go b/cmd/api/src/api/v2/ad_entity_test.go index 8be33efde3f..adc901ef06a 100644 --- a/cmd/api/src/api/v2/ad_entity_test.go +++ b/cmd/api/src/api/v2/ad_entity_test.go @@ -28,7 +28,11 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/api" v2 "github.com/specterops/bloodhound/cmd/api/src/api/v2" "github.com/specterops/bloodhound/cmd/api/src/api/v2/apitest" + "github.com/specterops/bloodhound/cmd/api/src/auth" + "github.com/specterops/bloodhound/cmd/api/src/ctx" + "github.com/specterops/bloodhound/cmd/api/src/model" "github.com/specterops/bloodhound/cmd/api/src/queries/mocks" + "github.com/specterops/bloodhound/cmd/api/src/services/dogtags" "github.com/specterops/bloodhound/cmd/api/src/utils/test" "github.com/specterops/bloodhound/packages/go/headers" "github.com/specterops/bloodhound/packages/go/mediatypes" @@ -41,7 +45,15 @@ func TestResources_GetComputerEntityInfo(t *testing.T) { var ( mockCtrl = gomock.NewController(t) mockGraph = mocks.NewMockGraph(mockCtrl) - resources = v2.Resources{GraphQuery: mockGraph} + resources = v2.Resources{GraphQuery: mockGraph, DogTags: dogtags.NewTestService(dogtags.TestOverrides{})} + + bheCtx = ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: model.User{}, + Session: model.UserSession{}, + }, + } ) defer mockCtrl.Finish() @@ -49,6 +61,9 @@ func TestResources_GetComputerEntityInfo(t *testing.T) { Run([]apitest.Case{ { Name: "NoObjectID", + Input: func(input *apitest.Input) { + apitest.SetContext(input, bheCtx.ConstructGoContext()) + }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusBadRequest) apitest.BodyContains(output, "error reading objectid:") @@ -59,6 +74,7 @@ func TestResources_GetComputerEntityInfo(t *testing.T) { Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") apitest.AddQueryParam(input, "counts", "foo") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusBadRequest) @@ -69,6 +85,7 @@ func TestResources_GetComputerEntityInfo(t *testing.T) { Name: "GraphDBNotFoundError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -84,6 +101,7 @@ func TestResources_GetComputerEntityInfo(t *testing.T) { Name: "GraphDBGetEntityByObjectIdError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -99,6 +117,7 @@ func TestResources_GetComputerEntityInfo(t *testing.T) { Name: "Success", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -117,6 +136,7 @@ func TestResources_GetComputerEntityInfo(t *testing.T) { Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") apitest.AddQueryParam(input, "counts", "false") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -134,7 +154,14 @@ func TestResources_GetDomainEntityInfo(t *testing.T) { var ( mockCtrl = gomock.NewController(t) mockGraph = mocks.NewMockGraph(mockCtrl) - resources = v2.Resources{GraphQuery: mockGraph} + resources = v2.Resources{GraphQuery: mockGraph, DogTags: dogtags.NewTestService(dogtags.TestOverrides{})} + bheCtx = ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: model.User{}, + Session: model.UserSession{}, + }, + } ) defer mockCtrl.Finish() @@ -142,6 +169,9 @@ func TestResources_GetDomainEntityInfo(t *testing.T) { Run([]apitest.Case{ { Name: "NoObjectID", + Input: func(input *apitest.Input) { + apitest.SetContext(input, bheCtx.ConstructGoContext()) + }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusBadRequest) apitest.BodyContains(output, "error reading objectid:") @@ -152,6 +182,7 @@ func TestResources_GetDomainEntityInfo(t *testing.T) { Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") apitest.AddQueryParam(input, "counts", "foo") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusBadRequest) @@ -162,6 +193,7 @@ func TestResources_GetDomainEntityInfo(t *testing.T) { Name: "GraphDBNotFoundError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -177,6 +209,7 @@ func TestResources_GetDomainEntityInfo(t *testing.T) { Name: "GraphDBGetEntityByObjectIdError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -192,6 +225,7 @@ func TestResources_GetDomainEntityInfo(t *testing.T) { Name: "Success", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -210,6 +244,7 @@ func TestResources_GetDomainEntityInfo(t *testing.T) { Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") apitest.AddQueryParam(input, "counts", "false") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -333,7 +368,14 @@ func TestResources_GetGPOEntityInfo(t *testing.T) { var ( mockCtrl = gomock.NewController(t) mockGraph = mocks.NewMockGraph(mockCtrl) - resources = v2.Resources{GraphQuery: mockGraph} + resources = v2.Resources{GraphQuery: mockGraph, DogTags: dogtags.NewTestService(dogtags.TestOverrides{})} + bheCtx = ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: model.User{}, + Session: model.UserSession{}, + }, + } ) defer mockCtrl.Finish() @@ -341,6 +383,9 @@ func TestResources_GetGPOEntityInfo(t *testing.T) { Run([]apitest.Case{ { Name: "NoObjectID", + Input: func(input *apitest.Input) { + apitest.SetContext(input, bheCtx.ConstructGoContext()) + }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusBadRequest) apitest.BodyContains(output, "error reading objectid:") @@ -351,6 +396,7 @@ func TestResources_GetGPOEntityInfo(t *testing.T) { Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") apitest.AddQueryParam(input, "counts", "foo") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusBadRequest) @@ -361,6 +407,7 @@ func TestResources_GetGPOEntityInfo(t *testing.T) { Name: "GraphDBNotFoundError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -376,6 +423,7 @@ func TestResources_GetGPOEntityInfo(t *testing.T) { Name: "GraphDBGetEntityByObjectIdError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -391,6 +439,7 @@ func TestResources_GetGPOEntityInfo(t *testing.T) { Name: "Success", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -409,6 +458,7 @@ func TestResources_GetGPOEntityInfo(t *testing.T) { Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") apitest.AddQueryParam(input, "counts", "false") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -426,7 +476,14 @@ func TestResources_GetOUEntityInfo(t *testing.T) { var ( mockCtrl = gomock.NewController(t) mockGraph = mocks.NewMockGraph(mockCtrl) - resources = v2.Resources{GraphQuery: mockGraph} + resources = v2.Resources{GraphQuery: mockGraph, DogTags: dogtags.NewTestService(dogtags.TestOverrides{})} + bheCtx = ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: model.User{}, + Session: model.UserSession{}, + }, + } ) defer mockCtrl.Finish() @@ -434,6 +491,9 @@ func TestResources_GetOUEntityInfo(t *testing.T) { Run([]apitest.Case{ { Name: "NoObjectID", + Input: func(input *apitest.Input) { + apitest.SetContext(input, bheCtx.ConstructGoContext()) + }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusBadRequest) apitest.BodyContains(output, "error reading objectid:") @@ -444,6 +504,7 @@ func TestResources_GetOUEntityInfo(t *testing.T) { Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") apitest.AddQueryParam(input, "counts", "foo") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusBadRequest) @@ -454,6 +515,7 @@ func TestResources_GetOUEntityInfo(t *testing.T) { Name: "GraphDBNotFoundError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -469,6 +531,7 @@ func TestResources_GetOUEntityInfo(t *testing.T) { Name: "GraphDBGetEntityByObjectIdError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -484,6 +547,7 @@ func TestResources_GetOUEntityInfo(t *testing.T) { Name: "Success", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -502,6 +566,7 @@ func TestResources_GetOUEntityInfo(t *testing.T) { Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") apitest.AddQueryParam(input, "counts", "false") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -519,7 +584,14 @@ func TestResources_GetUserEntityInfo(t *testing.T) { var ( mockCtrl = gomock.NewController(t) mockGraph = mocks.NewMockGraph(mockCtrl) - resources = v2.Resources{GraphQuery: mockGraph} + resources = v2.Resources{GraphQuery: mockGraph, DogTags: dogtags.NewTestService(dogtags.TestOverrides{})} + bheCtx = ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: model.User{}, + Session: model.UserSession{}, + }, + } ) defer mockCtrl.Finish() @@ -527,6 +599,9 @@ func TestResources_GetUserEntityInfo(t *testing.T) { Run([]apitest.Case{ { Name: "NoObjectID", + Input: func(input *apitest.Input) { + apitest.SetContext(input, bheCtx.ConstructGoContext()) + }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusBadRequest) apitest.BodyContains(output, "error reading objectid:") @@ -537,6 +612,7 @@ func TestResources_GetUserEntityInfo(t *testing.T) { Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") apitest.AddQueryParam(input, "counts", "foo") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusBadRequest) @@ -547,6 +623,7 @@ func TestResources_GetUserEntityInfo(t *testing.T) { Name: "GraphDBNotFoundError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -562,6 +639,7 @@ func TestResources_GetUserEntityInfo(t *testing.T) { Name: "GraphDBGetEntityByObjectIdError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -577,6 +655,7 @@ func TestResources_GetUserEntityInfo(t *testing.T) { Name: "Success", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -595,6 +674,7 @@ func TestResources_GetUserEntityInfo(t *testing.T) { Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") apitest.AddQueryParam(input, "counts", "false") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -612,7 +692,14 @@ func TestResources_GetGroupEntityInfo(t *testing.T) { var ( mockCtrl = gomock.NewController(t) mockGraph = mocks.NewMockGraph(mockCtrl) - resources = v2.Resources{GraphQuery: mockGraph} + resources = v2.Resources{GraphQuery: mockGraph, DogTags: dogtags.NewTestService(dogtags.TestOverrides{})} + bheCtx = ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: model.User{}, + Session: model.UserSession{}, + }, + } ) defer mockCtrl.Finish() @@ -620,6 +707,9 @@ func TestResources_GetGroupEntityInfo(t *testing.T) { Run([]apitest.Case{ { Name: "NoObjectID", + Input: func(input *apitest.Input) { + apitest.SetContext(input, bheCtx.ConstructGoContext()) + }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusBadRequest) apitest.BodyContains(output, "error reading objectid:") @@ -630,6 +720,7 @@ func TestResources_GetGroupEntityInfo(t *testing.T) { Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") apitest.AddQueryParam(input, "counts", "foo") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusBadRequest) @@ -640,6 +731,7 @@ func TestResources_GetGroupEntityInfo(t *testing.T) { Name: "GraphDBNotFoundError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -655,6 +747,7 @@ func TestResources_GetGroupEntityInfo(t *testing.T) { Name: "GraphDBGetEntityByObjectIdError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -670,6 +763,7 @@ func TestResources_GetGroupEntityInfo(t *testing.T) { Name: "Success", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -688,6 +782,7 @@ func TestResources_GetGroupEntityInfo(t *testing.T) { Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") apitest.AddQueryParam(input, "counts", "false") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -713,10 +808,12 @@ func TestResources_GetBaseEntityInfo(t *testing.T) { responseHeader http.Header } type testData struct { - name string - buildRequest func() *http.Request - setupMocks func(t *testing.T, mock *mock) - expected expected + name string + buildRequest func() *http.Request + setupMocks func(t *testing.T, mock *mock) + dogTagsOverrides dogtags.TestOverrides + user model.User + expected expected } tt := []testData{ @@ -840,13 +937,23 @@ func TestResources_GetBaseEntityInfo(t *testing.T) { resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } response := httptest.NewRecorder() + bheCtx := ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: testCase.user, + Session: model.UserSession{}, + }, + } + requestWithCtx := request.WithContext(bheCtx.ConstructGoContext()) + router := mux.NewRouter() - router.HandleFunc(fmt.Sprintf("/api/v2/base/{%s}", api.URIPathVariableObjectID), resources.GetBaseEntityInfo).Methods(request.Method) - router.ServeHTTP(response, request) + router.HandleFunc(fmt.Sprintf("/api/v2/base/{%s}", api.URIPathVariableObjectID), resources.GetBaseEntityInfo).Methods(requestWithCtx.Method) + router.ServeHTTP(response, requestWithCtx) status, header, body := test.ProcessResponse(t, response) @@ -869,10 +976,12 @@ func TestResources_GetContainerEntityInfo(t *testing.T) { responseHeader http.Header } type testData struct { - name string - buildRequest func() *http.Request - setupMocks func(t *testing.T, mock *mock) - expected expected + name string + buildRequest func() *http.Request + setupMocks func(t *testing.T, mock *mock) + dogTagsOverrides dogtags.TestOverrides + user model.User + expected expected } tt := []testData{ @@ -993,15 +1102,25 @@ func TestResources_GetContainerEntityInfo(t *testing.T) { request := testCase.buildRequest() testCase.setupMocks(t, mocks) + bheCtx := ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: testCase.user, + Session: model.UserSession{}, + }, + } + requestWithCtx := request.WithContext(bheCtx.ConstructGoContext()) + resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } response := httptest.NewRecorder() router := mux.NewRouter() - router.HandleFunc(fmt.Sprintf("/api/v2/containers/{%s}", api.URIPathVariableObjectID), resources.GetContainerEntityInfo).Methods(request.Method) - router.ServeHTTP(response, request) + router.HandleFunc(fmt.Sprintf("/api/v2/containers/{%s}", api.URIPathVariableObjectID), resources.GetContainerEntityInfo).Methods(requestWithCtx.Method) + router.ServeHTTP(response, requestWithCtx) status, header, body := test.ProcessResponse(t, response) @@ -1024,10 +1143,12 @@ func TestResources_GetAIACAEntityInfo(t *testing.T) { responseHeader http.Header } type testData struct { - name string - buildRequest func() *http.Request - setupMocks func(t *testing.T, mock *mock) - expected expected + name string + buildRequest func() *http.Request + setupMocks func(t *testing.T, mock *mock) + user model.User + dogTagsOverrides dogtags.TestOverrides + expected expected } tt := []testData{ @@ -1146,17 +1267,27 @@ func TestResources_GetAIACAEntityInfo(t *testing.T) { } request := testCase.buildRequest() + bheCtx := ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: testCase.user, + Session: model.UserSession{}, + }, + } + requestWithCtx := request.WithContext(bheCtx.ConstructGoContext()) + testCase.setupMocks(t, mocks) resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } response := httptest.NewRecorder() router := mux.NewRouter() - router.HandleFunc(fmt.Sprintf("/api/v2/aiacas/{%s}", api.URIPathVariableObjectID), resources.GetAIACAEntityInfo).Methods(request.Method) - router.ServeHTTP(response, request) + router.HandleFunc(fmt.Sprintf("/api/v2/aiacas/{%s}", api.URIPathVariableObjectID), resources.GetAIACAEntityInfo).Methods(requestWithCtx.Method) + router.ServeHTTP(response, requestWithCtx) status, header, body := test.ProcessResponse(t, response) @@ -1179,10 +1310,12 @@ func TestResources_GetRootCAEntityInfo(t *testing.T) { responseHeader http.Header } type testData struct { - name string - buildRequest func() *http.Request - setupMocks func(t *testing.T, mock *mock) - expected expected + name string + buildRequest func() *http.Request + setupMocks func(t *testing.T, mock *mock) + user model.User + dogTagsOverrides dogtags.TestOverrides + expected expected } tt := []testData{ @@ -1294,17 +1427,27 @@ func TestResources_GetRootCAEntityInfo(t *testing.T) { request := testCase.buildRequest() + bheCtx := ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: testCase.user, + Session: model.UserSession{}, + }, + } + requestWithCtx := request.WithContext(bheCtx.ConstructGoContext()) + testCase.setupMocks(t, mocks) resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } response := httptest.NewRecorder() router := mux.NewRouter() - router.HandleFunc(fmt.Sprintf("/api/v2/rootcas/{%s}", api.URIPathVariableObjectID), resources.GetRootCAEntityInfo).Methods(request.Method) - router.ServeHTTP(response, request) + router.HandleFunc(fmt.Sprintf("/api/v2/rootcas/{%s}", api.URIPathVariableObjectID), resources.GetRootCAEntityInfo).Methods(requestWithCtx.Method) + router.ServeHTTP(response, requestWithCtx) status, header, body := test.ProcessResponse(t, response) @@ -1327,10 +1470,12 @@ func TestResources_GetEnterpriseCAEntityInfo(t *testing.T) { responseHeader http.Header } type testData struct { - name string - buildRequest func() *http.Request - setupMocks func(t *testing.T, mock *mock) - expected expected + name string + buildRequest func() *http.Request + setupMocks func(t *testing.T, mock *mock) + user model.User + dogTagsOverrides dogtags.TestOverrides + expected expected } tt := []testData{ @@ -1449,17 +1594,27 @@ func TestResources_GetEnterpriseCAEntityInfo(t *testing.T) { } request := testCase.buildRequest() + bheCtx := ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: testCase.user, + Session: model.UserSession{}, + }, + } + requestWithCtx := request.WithContext(bheCtx.ConstructGoContext()) + testCase.setupMocks(t, mocks) resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } response := httptest.NewRecorder() router := mux.NewRouter() - router.HandleFunc(fmt.Sprintf("/api/v2/enterprisecas/{%s}", api.URIPathVariableObjectID), resources.GetEnterpriseCAEntityInfo).Methods(request.Method) - router.ServeHTTP(response, request) + router.HandleFunc(fmt.Sprintf("/api/v2/enterprisecas/{%s}", api.URIPathVariableObjectID), resources.GetEnterpriseCAEntityInfo).Methods(requestWithCtx.Method) + router.ServeHTTP(response, requestWithCtx) status, header, body := test.ProcessResponse(t, response) @@ -1482,10 +1637,12 @@ func TestResources_GetNTAuthStoreEntityInfo(t *testing.T) { responseHeader http.Header } type testData struct { - name string - buildRequest func() *http.Request - setupMocks func(t *testing.T, mock *mock) - expected expected + name string + buildRequest func() *http.Request + setupMocks func(t *testing.T, mock *mock) + user model.User + dogTagsOverrides dogtags.TestOverrides + expected expected } tt := []testData{ @@ -1604,17 +1761,27 @@ func TestResources_GetNTAuthStoreEntityInfo(t *testing.T) { } request := testCase.buildRequest() + bheCtx := ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: testCase.user, + Session: model.UserSession{}, + }, + } + requestWithCtx := request.WithContext(bheCtx.ConstructGoContext()) + testCase.setupMocks(t, mocks) resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } response := httptest.NewRecorder() router := mux.NewRouter() - router.HandleFunc(fmt.Sprintf("/api/v2/ntauthstores/{%s}", api.URIPathVariableObjectID), resources.GetNTAuthStoreEntityInfo).Methods(request.Method) - router.ServeHTTP(response, request) + router.HandleFunc(fmt.Sprintf("/api/v2/ntauthstores/{%s}", api.URIPathVariableObjectID), resources.GetNTAuthStoreEntityInfo).Methods(requestWithCtx.Method) + router.ServeHTTP(response, requestWithCtx) status, header, body := test.ProcessResponse(t, response) @@ -1637,10 +1804,12 @@ func TestResources_GetCertTemplateEntityInfo(t *testing.T) { responseHeader http.Header } type testData struct { - name string - buildRequest func() *http.Request - setupMocks func(t *testing.T, mock *mock) - expected expected + name string + buildRequest func() *http.Request + setupMocks func(t *testing.T, mock *mock) + user model.User + dogTagsOverrides dogtags.TestOverrides + expected expected } tt := []testData{ @@ -1759,17 +1928,27 @@ func TestResources_GetCertTemplateEntityInfo(t *testing.T) { } request := testCase.buildRequest() + bheCtx := ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: testCase.user, + Session: model.UserSession{}, + }, + } + requestWithCtx := request.WithContext(bheCtx.ConstructGoContext()) + testCase.setupMocks(t, mocks) resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } response := httptest.NewRecorder() router := mux.NewRouter() - router.HandleFunc(fmt.Sprintf("/api/v2/certtemplates/{%s}", api.URIPathVariableObjectID), resources.GetCertTemplateEntityInfo).Methods(request.Method) - router.ServeHTTP(response, request) + router.HandleFunc(fmt.Sprintf("/api/v2/certtemplates/{%s}", api.URIPathVariableObjectID), resources.GetCertTemplateEntityInfo).Methods(requestWithCtx.Method) + router.ServeHTTP(response, requestWithCtx) status, header, body := test.ProcessResponse(t, response) @@ -1792,10 +1971,12 @@ func TestResources_GetIssuancePolicyEntityInfo(t *testing.T) { responseHeader http.Header } type testData struct { - name string - buildRequest func() *http.Request - setupMocks func(t *testing.T, mock *mock) - expected expected + name string + buildRequest func() *http.Request + setupMocks func(t *testing.T, mock *mock) + user model.User + dogTagsOverrides dogtags.TestOverrides + expected expected } tt := []testData{ @@ -1914,17 +2095,27 @@ func TestResources_GetIssuancePolicyEntityInfo(t *testing.T) { } request := testCase.buildRequest() + bheCtx := ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: testCase.user, + Session: model.UserSession{}, + }, + } + requestWithCtx := request.WithContext(bheCtx.ConstructGoContext()) + testCase.setupMocks(t, mocks) resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } response := httptest.NewRecorder() router := mux.NewRouter() - router.HandleFunc(fmt.Sprintf("/api/v2/issuancepolicies/{%s}", api.URIPathVariableObjectID), resources.GetIssuancePolicyEntityInfo).Methods(request.Method) - router.ServeHTTP(response, request) + router.HandleFunc(fmt.Sprintf("/api/v2/issuancepolicies/{%s}", api.URIPathVariableObjectID), resources.GetIssuancePolicyEntityInfo).Methods(requestWithCtx.Method) + router.ServeHTTP(response, requestWithCtx) status, header, body := test.ProcessResponse(t, response) diff --git a/cmd/api/src/api/v2/ad_related_entity_test.go b/cmd/api/src/api/v2/ad_related_entity_test.go index 5385efc0193..7b3ef1f9bdd 100644 --- a/cmd/api/src/api/v2/ad_related_entity_test.go +++ b/cmd/api/src/api/v2/ad_related_entity_test.go @@ -28,10 +28,14 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/api" v2 "github.com/specterops/bloodhound/cmd/api/src/api/v2" "github.com/specterops/bloodhound/cmd/api/src/api/v2/apitest" + "github.com/specterops/bloodhound/cmd/api/src/auth" + "github.com/specterops/bloodhound/cmd/api/src/ctx" dbMocks "github.com/specterops/bloodhound/cmd/api/src/database/mocks" + "github.com/specterops/bloodhound/cmd/api/src/model" "github.com/specterops/bloodhound/cmd/api/src/model/appcfg" "github.com/specterops/bloodhound/cmd/api/src/queries" "github.com/specterops/bloodhound/cmd/api/src/queries/mocks" + "github.com/specterops/bloodhound/cmd/api/src/services/dogtags" "github.com/specterops/bloodhound/cmd/api/src/utils/test" "github.com/specterops/dawgs/ops" "github.com/stretchr/testify/assert" @@ -43,15 +47,26 @@ func setup(t *testing.T) (*gomock.Controller, *mocks.MockGraph, *dbMocks.MockDat mockCtrl = gomock.NewController(t) mockGraph = mocks.NewMockGraph(mockCtrl) mockDB = dbMocks.NewMockDatabase(mockCtrl) - resources = v2.Resources{GraphQuery: mockGraph, DB: mockDB} + resources = v2.Resources{GraphQuery: mockGraph, DB: mockDB, DogTags: dogtags.NewTestService(dogtags.TestOverrides{})} ) return mockCtrl, mockGraph, mockDB, resources } func setupCases(mockGraph *mocks.MockGraph, mockDB *dbMocks.MockDatabase) []apitest.Case { + bheCtx := ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: model.User{}, + Session: model.UserSession{}, + }, + } + return []apitest.Case{ { Name: "RepoGetEntityQueryParamsError", + Input: func(input *apitest.Input) { + apitest.SetContext(input, bheCtx.ConstructGoContext()) + }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusBadRequest) apitest.BodyContains(output, "no object ID found in request") @@ -61,6 +76,7 @@ func setupCases(mockGraph *mocks.MockGraph, mockDB *dbMocks.MockDatabase) []apit Name: "GraphDBGetADEntityQueryResultGraphUnsupportedError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -79,6 +95,7 @@ func setupCases(mockGraph *mocks.MockGraph, mockDB *dbMocks.MockDatabase) []apit Name: "GraphDBGetADEntityQueryResultUnsupportedDataTypeError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -97,6 +114,7 @@ func setupCases(mockGraph *mocks.MockGraph, mockDB *dbMocks.MockDatabase) []apit Name: "GraphDBGetADEntityQueryResultMemoryLimitError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -115,6 +133,7 @@ func setupCases(mockGraph *mocks.MockGraph, mockDB *dbMocks.MockDatabase) []apit Name: "GraphDBGetADEntityQueryResultUnexpectedError", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -134,6 +153,7 @@ func setupCases(mockGraph *mocks.MockGraph, mockDB *dbMocks.MockDatabase) []apit Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") apitest.AddQueryParam(input, "type", "graph") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -145,8 +165,8 @@ func setupCases(mockGraph *mocks.MockGraph, mockDB *dbMocks.MockDatabase) []apit }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusOK) - //This flat unnested shape maintains the current api contract for a type=graph query - //Assert that the response does not contain pagination properties + // This flat unnested shape maintains the current api contract for a type=graph query + // Assert that the response does not contain pagination properties apitest.BodyNotContains(output, "data") apitest.BodyNotContains(output, "skip") apitest.BodyNotContains(output, "limit") @@ -157,8 +177,9 @@ func setupCases(mockGraph *mocks.MockGraph, mockDB *dbMocks.MockDatabase) []apit Name: "Success", Input: func(input *apitest.Input) { apitest.SetURLVar(input, "object_id", "1") - //Delete the type=graph param so that we get list results + // Delete the type=graph param so that we get list results apitest.DeleteQueryParam(input, "type") + apitest.SetContext(input, bheCtx.ConstructGoContext()) }, Setup: func() { mockGraph.EXPECT(). @@ -170,7 +191,7 @@ func setupCases(mockGraph *mocks.MockGraph, mockDB *dbMocks.MockDatabase) []apit }, Test: func(output apitest.Output) { apitest.StatusCode(output, http.StatusOK) - //List results are nested under "data" and the response contains other pagination properties + // List results are nested under "data" and the response contains other pagination properties apitest.BodyContains(output, "data") apitest.BodyContains(output, "skip") apitest.BodyContains(output, "limit") @@ -553,10 +574,12 @@ func TestResources_ListADIssuancePolicyLinkedCertTemplates(t *testing.T) { responseHeader http.Header } type testData struct { - name string - buildRequest func() *http.Request - setupMocks func(t *testing.T, mock *mock) - expected expected + name string + buildRequest func() *http.Request + setupMocks func(t *testing.T, mock *mock) + user model.User + dogTagsOverrides dogtags.TestOverrides + expected expected } tt := []testData{ @@ -703,18 +726,28 @@ func TestResources_ListADIssuancePolicyLinkedCertTemplates(t *testing.T) { } request := testCase.buildRequest() + bheCtx := ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: testCase.user, + Session: model.UserSession{}, + }, + } + requestWithCtx := request.WithContext(bheCtx.ConstructGoContext()) + testCase.setupMocks(t, mocks) resources := v2.Resources{ DB: mocks.mockDatabase, GraphQuery: mocks.mockGraphQuery, + DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } response := httptest.NewRecorder() router := mux.NewRouter() - router.HandleFunc(fmt.Sprintf("/api/v2/issuancepolicies/{%s}/linkedtemplates", api.URIPathVariableObjectID), resources.ListADIssuancePolicyLinkedCertTemplates).Methods(request.Method) - router.ServeHTTP(response, request) + router.HandleFunc(fmt.Sprintf("/api/v2/issuancepolicies/{%s}/linkedtemplates", api.URIPathVariableObjectID), resources.ListADIssuancePolicyLinkedCertTemplates).Methods(requestWithCtx.Method) + router.ServeHTTP(response, requestWithCtx) status, header, body := test.ProcessResponse(t, response) diff --git a/cmd/api/src/api/v2/azure_test.go b/cmd/api/src/api/v2/azure_test.go index 5c85418e525..6cb0f8956ec 100644 --- a/cmd/api/src/api/v2/azure_test.go +++ b/cmd/api/src/api/v2/azure_test.go @@ -25,6 +25,10 @@ import ( "testing" "github.com/gorilla/mux" + "github.com/specterops/bloodhound/cmd/api/src/auth" + "github.com/specterops/bloodhound/cmd/api/src/ctx" + "github.com/specterops/bloodhound/cmd/api/src/model" + "github.com/specterops/bloodhound/cmd/api/src/services/dogtags" "github.com/specterops/bloodhound/packages/go/analysis/azure" graphmocks "github.com/specterops/bloodhound/cmd/api/src/vendormocks/dawgs/graph" @@ -50,10 +54,12 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { responseHeader http.Header } type testData struct { - name string - buildRequest func() *http.Request - setupMocks func(t *testing.T, mock *mock) - expected expected + name string + buildRequest func() *http.Request + setupMocks func(t *testing.T, mock *mock) + user model.User + dogTagsOverrides dogtags.TestOverrides + expected expected } tt := []testData{ @@ -288,17 +294,27 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { } request := testCase.buildRequest() + bheCtx := ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: testCase.user, + Session: model.UserSession{}, + }, + } + requestWithCtx := request.WithContext(bheCtx.ConstructGoContext()) + testCase.setupMocks(t, mocks) resources := v2.Resources{ - Graph: mocks.mockDB, + Graph: mocks.mockDB, + DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } response := httptest.NewRecorder() router := mux.NewRouter() - router.HandleFunc("/api/v2/azure/{entity_type}", resources.GetAZEntity).Methods(request.Method) - router.ServeHTTP(response, request) + router.HandleFunc("/api/v2/azure/{entity_type}", resources.GetAZEntity).Methods(requestWithCtx.Method) + router.ServeHTTP(response, requestWithCtx) status, header, body := test.ProcessResponse(t, response) @@ -936,10 +952,12 @@ func TestManagementResource_GetAZEntity(t *testing.T) { responseHeader http.Header } type testData struct { - name string - buildRequest func() *http.Request - setupMocks func(t *testing.T, mock *mock) - expected expected + name string + buildRequest func() *http.Request + setupMocks func(t *testing.T, mock *mock) + user model.User + dogTagsOverrides dogtags.TestOverrides + expected expected } tt := []testData{ @@ -1068,17 +1086,27 @@ func TestManagementResource_GetAZEntity(t *testing.T) { } request := testCase.buildRequest() + bheCtx := ctx.Context{ + AuthCtx: auth.Context{ + PermissionOverrides: auth.PermissionOverrides{}, + Owner: testCase.user, + Session: model.UserSession{}, + }, + } + requestWithCtx := request.WithContext(bheCtx.ConstructGoContext()) + testCase.setupMocks(t, mocks) resources := v2.Resources{ - Graph: mocks.mockDB, + Graph: mocks.mockDB, + DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } response := httptest.NewRecorder() router := mux.NewRouter() - router.HandleFunc("/api/v2/azure/{entity_type}", resources.GetAZEntity).Methods(request.Method) - router.ServeHTTP(response, request) + router.HandleFunc("/api/v2/azure/{entity_type}", resources.GetAZEntity).Methods(requestWithCtx.Method) + router.ServeHTTP(response, requestWithCtx) status, header, body := test.ProcessResponse(t, response) diff --git a/cmd/api/src/api/v2/etac.go b/cmd/api/src/api/v2/etac.go index 47edf5bc087..fcdd4e92a1d 100644 --- a/cmd/api/src/api/v2/etac.go +++ b/cmd/api/src/api/v2/etac.go @@ -27,6 +27,7 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/queries" "github.com/specterops/bloodhound/cmd/api/src/services/dogtags" "github.com/specterops/bloodhound/packages/go/graphschema/ad" + "github.com/specterops/bloodhound/packages/go/graphschema/azure" "github.com/specterops/dawgs/graph" ) @@ -67,12 +68,28 @@ func CheckUserAccessToEnvironments(ctx context.Context, db database.EnvironmentT // CheckUserHasAccessToNodeById returns whether a user has access to view this node based on their ETAC list func CheckUserHasAccessToNodeById(ctx context.Context, db database.Database, graphQuery queries.Graph, dogTagsService dogtags.Service, user model.User, objectId string, kind graph.Kind) (bool, error) { - if etacEnabled := dogTagsService.GetFlagAsBool(dogtags.ETAC_ENABLED); etacEnabled { + if ShouldFilterForETAC(dogTagsService, user) { node, err := graphQuery.GetEntityByObjectId(ctx, objectId, kind) if err != nil || node == nil { - } else if domainSid, err := node.Properties.Get(ad.DomainSID.String()).String(); err != nil { return false, err - } else if hasAccess, err := CheckUserAccessToEnvironments(ctx, db, user, domainSid); err != nil { + } + + var environmentId string + if tenantID := node.Kinds.ContainsOneOf(azure.Entity); tenantID { + if id, err := node.Properties.Get(azure.TenantID.String()).String(); err != nil { + return false, err + } else { + environmentId = id + } + } else if domainSID := node.Kinds.ContainsOneOf(ad.Entity); domainSID { + if id, err := node.Properties.Get(ad.DomainSID.String()).String(); err != nil { + return false, err + } else { + environmentId = id + } + } + + if hasAccess, err := CheckUserAccessToEnvironments(ctx, db, user, environmentId); err != nil { return false, err } else if !hasAccess { return false, nil From 003b0d77ed1f09de9129629dea55fe42e1ce68ed Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Thu, 19 Feb 2026 15:19:40 -0600 Subject: [PATCH 03/11] BED-7403 error when environmentid not found --- cmd/api/src/api/v2/etac.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/api/src/api/v2/etac.go b/cmd/api/src/api/v2/etac.go index fcdd4e92a1d..854e4c461c5 100644 --- a/cmd/api/src/api/v2/etac.go +++ b/cmd/api/src/api/v2/etac.go @@ -89,6 +89,10 @@ func CheckUserHasAccessToNodeById(ctx context.Context, db database.Database, gra } } + if environmentId == "" { + return false, fmt.Errorf("could not find environment id for %s", objectId) + } + if hasAccess, err := CheckUserAccessToEnvironments(ctx, db, user, environmentId); err != nil { return false, err } else if !hasAccess { From 15f70f9e7c95cc9c472044da69301559389fe868 Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Thu, 19 Feb 2026 15:23:41 -0600 Subject: [PATCH 04/11] BED-7403 added error logging --- cmd/api/src/api/v2/ad_entity.go | 1 + cmd/api/src/api/v2/ad_related_entity.go | 1 + cmd/api/src/api/v2/azure.go | 1 + 3 files changed, 3 insertions(+) diff --git a/cmd/api/src/api/v2/ad_entity.go b/cmd/api/src/api/v2/ad_entity.go index 406be45abd3..eea6b8211a7 100644 --- a/cmd/api/src/api/v2/ad_entity.go +++ b/cmd/api/src/api/v2/ad_entity.go @@ -79,6 +79,7 @@ func (s *Resources) handleAdEntityInfoQuery(response http.ResponseWriter, reques } else if objectId, err := GetEntityObjectIDFromRequestPath(request); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf("error reading objectid: %v", err), request), response) } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, objectId, entityType); err != nil { + slog.ErrorContext(request.Context(), "error checking if user has access to node for ETAC", err) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) } else if !hasAccess { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) diff --git a/cmd/api/src/api/v2/ad_related_entity.go b/cmd/api/src/api/v2/ad_related_entity.go index 68da0ed51f5..af9ab4cd192 100644 --- a/cmd/api/src/api/v2/ad_related_entity.go +++ b/cmd/api/src/api/v2/ad_related_entity.go @@ -50,6 +50,7 @@ func (s *Resources) handleAdRelatedEntityQuery(response http.ResponseWriter, req if params, err := queries.BuildEntityQueryParams(request, queryName, pathDelegate, listDelegate); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf(api.FmtErrorResponseDetailsBadQueryParameters, err), request), response) } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, params.ObjectID, ad.Entity); err != nil { + slog.ErrorContext(request.Context(), "error checking if user has access to node for ETAC", err) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) } else if !hasAccess { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) diff --git a/cmd/api/src/api/v2/azure.go b/cmd/api/src/api/v2/azure.go index 715f0a5ea4c..b76a7a84ef7 100644 --- a/cmd/api/src/api/v2/azure.go +++ b/cmd/api/src/api/v2/azure.go @@ -428,6 +428,7 @@ func (s *Resources) GetAZEntity(response http.ResponseWriter, request *http.Requ } else if includeCounts, err := api.ParseOptionalBool(queryVars.Get(api.QueryParameterIncludeCounts), true); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsBadQueryParameterFilters, request), response) } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, objectID, graph.StringKind(entityType)); err != nil { + slog.ErrorContext(request.Context(), "error checking if user has access to node for ETAC", err) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) } else if !hasAccess { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) From 8eb3de04fc4a53407921aa78d790a51f0a825bee Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Thu, 19 Feb 2026 15:27:42 -0600 Subject: [PATCH 05/11] BED-7403 fix attr in logging --- cmd/api/src/api/v2/ad_entity.go | 3 ++- cmd/api/src/api/v2/ad_related_entity.go | 3 ++- cmd/api/src/api/v2/azure.go | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cmd/api/src/api/v2/ad_entity.go b/cmd/api/src/api/v2/ad_entity.go index eea6b8211a7..94e61def123 100644 --- a/cmd/api/src/api/v2/ad_entity.go +++ b/cmd/api/src/api/v2/ad_entity.go @@ -26,6 +26,7 @@ import ( bhCtx "github.com/specterops/bloodhound/cmd/api/src/ctx" adAnalysis "github.com/specterops/bloodhound/packages/go/analysis/ad" "github.com/specterops/bloodhound/packages/go/analysis/tiering" + "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/graphschema/ad" "github.com/specterops/bloodhound/packages/go/graphschema/common" "github.com/specterops/dawgs/graph" @@ -79,7 +80,7 @@ func (s *Resources) handleAdEntityInfoQuery(response http.ResponseWriter, reques } else if objectId, err := GetEntityObjectIDFromRequestPath(request); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf("error reading objectid: %v", err), request), response) } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, objectId, entityType); err != nil { - slog.ErrorContext(request.Context(), "error checking if user has access to node for ETAC", err) + slog.ErrorContext(request.Context(), "error checking if user has access to node for ETAC", attr.Error(err)) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) } else if !hasAccess { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) diff --git a/cmd/api/src/api/v2/ad_related_entity.go b/cmd/api/src/api/v2/ad_related_entity.go index af9ab4cd192..6002a6f14f3 100644 --- a/cmd/api/src/api/v2/ad_related_entity.go +++ b/cmd/api/src/api/v2/ad_related_entity.go @@ -29,6 +29,7 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/model/appcfg" "github.com/specterops/bloodhound/cmd/api/src/queries" adAnalysis "github.com/specterops/bloodhound/packages/go/analysis/ad" + "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/bloodhound/packages/go/graphschema/ad" "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/ops" @@ -50,7 +51,7 @@ func (s *Resources) handleAdRelatedEntityQuery(response http.ResponseWriter, req if params, err := queries.BuildEntityQueryParams(request, queryName, pathDelegate, listDelegate); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf(api.FmtErrorResponseDetailsBadQueryParameters, err), request), response) } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, params.ObjectID, ad.Entity); err != nil { - slog.ErrorContext(request.Context(), "error checking if user has access to node for ETAC", err) + slog.ErrorContext(request.Context(), "error checking if user has access to node for ETAC", attr.Error(err)) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) } else if !hasAccess { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) diff --git a/cmd/api/src/api/v2/azure.go b/cmd/api/src/api/v2/azure.go index b76a7a84ef7..2426cc0aa62 100644 --- a/cmd/api/src/api/v2/azure.go +++ b/cmd/api/src/api/v2/azure.go @@ -33,6 +33,7 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/model" "github.com/specterops/bloodhound/cmd/api/src/utils" "github.com/specterops/bloodhound/packages/go/analysis/azure" + "github.com/specterops/bloodhound/packages/go/bhlog/attr" "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/ops" ) @@ -428,7 +429,7 @@ func (s *Resources) GetAZEntity(response http.ResponseWriter, request *http.Requ } else if includeCounts, err := api.ParseOptionalBool(queryVars.Get(api.QueryParameterIncludeCounts), true); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsBadQueryParameterFilters, request), response) } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, objectID, graph.StringKind(entityType)); err != nil { - slog.ErrorContext(request.Context(), "error checking if user has access to node for ETAC", err) + slog.ErrorContext(request.Context(), "error checking if user has access to node for ETAC", attr.Error(err)) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) } else if !hasAccess { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) From bf8c5b23afffd87037b00e4ad1f591a58dc73800 Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Thu, 19 Feb 2026 15:32:14 -0600 Subject: [PATCH 06/11] BED-7403 fix linting --- cmd/api/src/api/v2/ad_entity.go | 2 +- cmd/api/src/api/v2/ad_related_entity.go | 2 +- cmd/api/src/api/v2/azure.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/api/src/api/v2/ad_entity.go b/cmd/api/src/api/v2/ad_entity.go index 94e61def123..cce55b3a0d5 100644 --- a/cmd/api/src/api/v2/ad_entity.go +++ b/cmd/api/src/api/v2/ad_entity.go @@ -80,7 +80,7 @@ func (s *Resources) handleAdEntityInfoQuery(response http.ResponseWriter, reques } else if objectId, err := GetEntityObjectIDFromRequestPath(request); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf("error reading objectid: %v", err), request), response) } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, objectId, entityType); err != nil { - slog.ErrorContext(request.Context(), "error checking if user has access to node for ETAC", attr.Error(err)) + slog.ErrorContext(request.Context(), "Error checking if user has access to node for ETAC", attr.Error(err)) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) } else if !hasAccess { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) diff --git a/cmd/api/src/api/v2/ad_related_entity.go b/cmd/api/src/api/v2/ad_related_entity.go index 6002a6f14f3..51a7e8cbe41 100644 --- a/cmd/api/src/api/v2/ad_related_entity.go +++ b/cmd/api/src/api/v2/ad_related_entity.go @@ -51,7 +51,7 @@ func (s *Resources) handleAdRelatedEntityQuery(response http.ResponseWriter, req if params, err := queries.BuildEntityQueryParams(request, queryName, pathDelegate, listDelegate); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf(api.FmtErrorResponseDetailsBadQueryParameters, err), request), response) } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, params.ObjectID, ad.Entity); err != nil { - slog.ErrorContext(request.Context(), "error checking if user has access to node for ETAC", attr.Error(err)) + slog.ErrorContext(request.Context(), "Error checking if user has access to node for ETAC", attr.Error(err)) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) } else if !hasAccess { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) diff --git a/cmd/api/src/api/v2/azure.go b/cmd/api/src/api/v2/azure.go index 2426cc0aa62..a34bc7fe53d 100644 --- a/cmd/api/src/api/v2/azure.go +++ b/cmd/api/src/api/v2/azure.go @@ -429,7 +429,7 @@ func (s *Resources) GetAZEntity(response http.ResponseWriter, request *http.Requ } else if includeCounts, err := api.ParseOptionalBool(queryVars.Get(api.QueryParameterIncludeCounts), true); err != nil { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsBadQueryParameterFilters, request), response) } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, objectID, graph.StringKind(entityType)); err != nil { - slog.ErrorContext(request.Context(), "error checking if user has access to node for ETAC", attr.Error(err)) + slog.ErrorContext(request.Context(), "Error checking if user has access to node for ETAC", attr.Error(err)) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) } else if !hasAccess { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) From 7f879c68f7c80acbd588282b1ff321f11e517947 Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Mon, 23 Feb 2026 10:44:41 -0600 Subject: [PATCH 07/11] BED-7403 updated tests to include ETAC testing --- cmd/api/src/api/v2/ad_entity_test.go | 1108 +++++++++++++++++- cmd/api/src/api/v2/ad_related_entity_test.go | 121 ++ 2 files changed, 1200 insertions(+), 29 deletions(-) diff --git a/cmd/api/src/api/v2/ad_entity_test.go b/cmd/api/src/api/v2/ad_entity_test.go index adc901ef06a..dd03349d15b 100644 --- a/cmd/api/src/api/v2/ad_entity_test.go +++ b/cmd/api/src/api/v2/ad_entity_test.go @@ -30,10 +30,12 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/api/v2/apitest" "github.com/specterops/bloodhound/cmd/api/src/auth" "github.com/specterops/bloodhound/cmd/api/src/ctx" + mocks_db "github.com/specterops/bloodhound/cmd/api/src/database/mocks" "github.com/specterops/bloodhound/cmd/api/src/model" "github.com/specterops/bloodhound/cmd/api/src/queries/mocks" "github.com/specterops/bloodhound/cmd/api/src/services/dogtags" "github.com/specterops/bloodhound/cmd/api/src/utils/test" + "github.com/specterops/bloodhound/packages/go/graphschema/ad" "github.com/specterops/bloodhound/packages/go/headers" "github.com/specterops/bloodhound/packages/go/mediatypes" "github.com/specterops/dawgs/graph" @@ -801,6 +803,7 @@ func TestResources_GetBaseEntityInfo(t *testing.T) { type mock struct { mockGraphQuery *mocks.MockGraph + mockDatabase *mocks_db.MockDatabase } type expected struct { responseBody string @@ -922,6 +925,134 @@ func TestResources_GetBaseEntityInfo(t *testing.T) { mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("Base")).Return(graph.NewNode(graph.ID(1), graph.NewProperties()), nil) }, }, + { + name: "Success: ETAC enabled AllEnvironments", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/base/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.Entity).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity}, + Properties: props, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, ad.Entity), gomock.Any()).Return(map[string]any{"results": "output"}) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: true, + }, + }, + { + name: "Success: ETAC enabled For Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/base/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.Entity).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity}, + Properties: props, + }, nil).Times(2) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, graph.StringsToKinds([]string{ad.Entity.String()})...), gomock.Any()).Return(map[string]any{"results": "output"}) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, + }, + }, + { + name: "Error: ETAC User Does Not have Access To Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/base/id", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.Entity).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity}, + Properties: graph.AsProperties(map[string]any{ + "domainsid": "12345", + }), + }, nil) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, nil) + }, + expected: expected{ + responseCode: http.StatusForbidden, + responseBody: `{"errors":[{"context":"","message":"Forbidden"}],"http_status":403,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, + }, + }, } for _, testCase := range tt { t.Run(testCase.name, func(t *testing.T) { @@ -930,6 +1061,7 @@ func TestResources_GetBaseEntityInfo(t *testing.T) { mocks := &mock{ mockGraphQuery: mocks.NewMockGraph(ctrl), + mockDatabase: mocks_db.NewMockDatabase(ctrl), } request := testCase.buildRequest() @@ -937,6 +1069,7 @@ func TestResources_GetBaseEntityInfo(t *testing.T) { resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DB: mocks.mockDatabase, DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } @@ -969,6 +1102,7 @@ func TestResources_GetContainerEntityInfo(t *testing.T) { type mock struct { mockGraphQuery *mocks.MockGraph + mockDatabase *mocks_db.MockDatabase } type expected struct { responseBody string @@ -1089,6 +1223,134 @@ func TestResources_GetContainerEntityInfo(t *testing.T) { mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("Container")).Return(graph.NewNode(graph.ID(1), graph.NewProperties()), nil) }, }, + { + name: "Success: ETAC enabled AllEnvironments", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/containers/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.Container).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Container}, + Properties: props, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, ad.Container), gomock.Any()).Return(map[string]any{"results": "output"}) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: true, + }, + }, + { + name: "Success: ETAC enabled For Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/containers/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.Container).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity, ad.Container}, + Properties: props, + }, nil).Times(2) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, graph.StringsToKinds([]string{ad.Entity.String(), ad.Container.String()})...), gomock.Any()).Return(map[string]any{"results": "output"}) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, + }, + }, + { + name: "Error: ETAC User Does Not have Access To Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/containers/id", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.Container).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity}, + Properties: graph.AsProperties(map[string]any{ + "domainsid": "12345", + }), + }, nil) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, nil) + }, + expected: expected{ + responseCode: http.StatusForbidden, + responseBody: `{"errors":[{"context":"","message":"Forbidden"}],"http_status":403,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, + }, + }, } for _, testCase := range tt { t.Run(testCase.name, func(t *testing.T) { @@ -1097,6 +1359,7 @@ func TestResources_GetContainerEntityInfo(t *testing.T) { mocks := &mock{ mockGraphQuery: mocks.NewMockGraph(ctrl), + mockDatabase: mocks_db.NewMockDatabase(ctrl), } request := testCase.buildRequest() @@ -1113,6 +1376,7 @@ func TestResources_GetContainerEntityInfo(t *testing.T) { resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DB: mocks.mockDatabase, DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } @@ -1136,6 +1400,7 @@ func TestResources_GetAIACAEntityInfo(t *testing.T) { type mock struct { mockGraphQuery *mocks.MockGraph + mockDatabase *mocks_db.MockDatabase } type expected struct { responseBody string @@ -1256,6 +1521,134 @@ func TestResources_GetAIACAEntityInfo(t *testing.T) { mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("AIACA")).Return(graph.NewNode(graph.ID(1), graph.NewProperties()), nil) }, }, + { + name: "Success: ETAC enabled AllEnvironments", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/aiacas/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.AIACA).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.AIACA}, + Properties: props, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, ad.AIACA), gomock.Any()).Return(map[string]any{"results": "output"}) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: true, + }, + }, + { + name: "Success: ETAC enabled For Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/aiacas/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.AIACA).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity, ad.AIACA}, + Properties: props, + }, nil).Times(2) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, graph.StringsToKinds([]string{ad.Entity.String(), ad.AIACA.String()})...), gomock.Any()).Return(map[string]any{"results": "output"}) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, + }, + }, + { + name: "Error: ETAC User Does Not have Access To Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/aiacas/id", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.AIACA).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity}, + Properties: graph.AsProperties(map[string]any{ + "domainsid": "12345", + }), + }, nil) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, nil) + }, + expected: expected{ + responseCode: http.StatusForbidden, + responseBody: `{"errors":[{"context":"","message":"Forbidden"}],"http_status":403,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, + }, + }, } for _, testCase := range tt { t.Run(testCase.name, func(t *testing.T) { @@ -1264,6 +1657,7 @@ func TestResources_GetAIACAEntityInfo(t *testing.T) { mocks := &mock{ mockGraphQuery: mocks.NewMockGraph(ctrl), + mockDatabase: mocks_db.NewMockDatabase(ctrl), } request := testCase.buildRequest() @@ -1280,6 +1674,7 @@ func TestResources_GetAIACAEntityInfo(t *testing.T) { resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DB: mocks.mockDatabase, DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } @@ -1303,6 +1698,7 @@ func TestResources_GetRootCAEntityInfo(t *testing.T) { type mock struct { mockGraphQuery *mocks.MockGraph + mockDatabase *mocks_db.MockDatabase } type expected struct { responseBody string @@ -1415,6 +1811,134 @@ func TestResources_GetRootCAEntityInfo(t *testing.T) { mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("RootCA")).Return(graph.NewNode(graph.ID(1), graph.NewProperties()), nil) }, }, + { + name: "Success: ETAC enabled AllEnvironments", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/rootcas/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.RootCA).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.RootCA}, + Properties: props, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, ad.RootCA), gomock.Any()).Return(map[string]any{"results": "output"}) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: true, + }, + }, + { + name: "Success: ETAC enabled For Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/rootcas/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.RootCA).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity, ad.RootCA}, + Properties: props, + }, nil).Times(2) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, graph.StringsToKinds([]string{ad.Entity.String(), ad.RootCA.String()})...), gomock.Any()).Return(map[string]any{"results": "output"}) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, + }, + }, + { + name: "Error: ETAC User Does Not have Access To Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/rootcas/id", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.RootCA).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity}, + Properties: graph.AsProperties(map[string]any{ + "domainsid": "12345", + }), + }, nil) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, nil) + }, + expected: expected{ + responseCode: http.StatusForbidden, + responseBody: `{"errors":[{"context":"","message":"Forbidden"}],"http_status":403,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, + }, + }, } for _, testCase := range tt { t.Run(testCase.name, func(t *testing.T) { @@ -1423,6 +1947,7 @@ func TestResources_GetRootCAEntityInfo(t *testing.T) { mocks := &mock{ mockGraphQuery: mocks.NewMockGraph(ctrl), + mockDatabase: mocks_db.NewMockDatabase(ctrl), } request := testCase.buildRequest() @@ -1440,6 +1965,7 @@ func TestResources_GetRootCAEntityInfo(t *testing.T) { resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DB: mocks.mockDatabase, DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } @@ -1463,6 +1989,7 @@ func TestResources_GetEnterpriseCAEntityInfo(t *testing.T) { type mock struct { mockGraphQuery *mocks.MockGraph + mockDatabase *mocks_db.MockDatabase } type expected struct { responseBody string @@ -1490,37 +2017,101 @@ func TestResources_GetEnterpriseCAEntityInfo(t *testing.T) { Method: http.MethodGet, } }, - setupMocks: func(t *testing.T, mock *mock) {}, + setupMocks: func(t *testing.T, mock *mock) {}, + expected: expected{ + responseCode: http.StatusBadRequest, + responseBody: `{"errors":[{"context":"","message":"there are errors in the query parameter filters specified"}],"http_status":400,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + }, + // Missing path parameters cannot be tested due to Gorilla Mux's strict route matching, which requires all defined path parameters to be present in the request URL for the route to match. + { + name: "Error: GetEntityByObjectId - Not Found", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/enterprisecas/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mocks *mock) { + t.Helper() + mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("EnterpriseCA")).Return(nil, graph.ErrNoResultsFound) + }, + expected: expected{ + responseCode: http.StatusNotFound, + responseBody: `{"errors":[{"context":"","message":"node not found"}],"http_status":404,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + }, + { + name: "Error: GetEntityByObjectId - Internal Server Error", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/enterprisecas/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mocks *mock) { + t.Helper() + mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("EnterpriseCA")).Return(nil, errors.New("error")) + }, + expected: expected{ + responseCode: http.StatusInternalServerError, + responseBody: `{"errors":[{"context":"","message":"error getting node: error"}],"http_status":500,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + }, + { + name: "Success: hydrateCounts - OK", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/enterprisecas/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mocks *mock) { + t.Helper() + mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("EnterpriseCA")).Return(graph.NewNode(graph.ID(1), graph.NewProperties()), nil) + mocks.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(1), graph.NewProperties()), gomock.Any()).Return(map[string]any{"results": "output"}) + }, expected: expected{ - responseCode: http.StatusBadRequest, - responseBody: `{"errors":[{"context":"","message":"there are errors in the query parameter filters specified"}],"http_status":400,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, responseHeader: http.Header{"Content-Type": []string{"application/json"}}, }, }, - // Missing path parameters cannot be tested due to Gorilla Mux's strict route matching, which requires all defined path parameters to be present in the request URL for the route to match. { - name: "Error: GetEntityByObjectId - Not Found", + name: "Success: !hydrateCounts - OK", buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ Path: "/api/v2/enterprisecas/id", - RawQuery: "counts=true", + RawQuery: "counts=false", }, Method: http.MethodGet, } }, - setupMocks: func(t *testing.T, mocks *mock) { - t.Helper() - mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("EnterpriseCA")).Return(nil, graph.ErrNoResultsFound) - }, expected: expected{ - responseCode: http.StatusNotFound, - responseBody: `{"errors":[{"context":"","message":"node not found"}],"http_status":404,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseCode: http.StatusOK, + responseBody: `{"data":{"props":null, "kinds": []}}`, responseHeader: http.Header{"Content-Type": []string{"application/json"}}, }, + setupMocks: func(t *testing.T, mocks *mock) { + t.Helper() + mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("EnterpriseCA")).Return(graph.NewNode(graph.ID(1), graph.NewProperties()), nil) + }, }, { - name: "Error: GetEntityByObjectId - Internal Server Error", + name: "Success: ETAC enabled AllEnvironments", buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ @@ -1530,18 +2121,34 @@ func TestResources_GetEnterpriseCAEntityInfo(t *testing.T) { Method: http.MethodGet, } }, - setupMocks: func(t *testing.T, mocks *mock) { + setupMocks: func(t *testing.T, mock *mock) { t.Helper() - mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("EnterpriseCA")).Return(nil, errors.New("error")) + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.EnterpriseCA).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.EnterpriseCA}, + Properties: props, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, ad.EnterpriseCA), gomock.Any()).Return(map[string]any{"results": "output"}) }, expected: expected{ - responseCode: http.StatusInternalServerError, - responseBody: `{"errors":[{"context":"","message":"error getting node: error"}],"http_status":500,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, responseHeader: http.Header{"Content-Type": []string{"application/json"}}, }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: true, + }, }, { - name: "Success: hydrateCounts - OK", + name: "Success: ETAC enabled For Specific Environment", buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ @@ -1551,36 +2158,84 @@ func TestResources_GetEnterpriseCAEntityInfo(t *testing.T) { Method: http.MethodGet, } }, - setupMocks: func(t *testing.T, mocks *mock) { + setupMocks: func(t *testing.T, mock *mock) { t.Helper() - mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("EnterpriseCA")).Return(graph.NewNode(graph.ID(1), graph.NewProperties()), nil) - mocks.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(1), graph.NewProperties()), gomock.Any()).Return(map[string]any{"results": "output"}) + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.EnterpriseCA).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity, ad.EnterpriseCA}, + Properties: props, + }, nil).Times(2) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, graph.StringsToKinds([]string{ad.Entity.String(), ad.EnterpriseCA.String()})...), gomock.Any()).Return(map[string]any{"results": "output"}) }, expected: expected{ responseCode: http.StatusOK, responseBody: `{"data":{"results":"output"}}`, responseHeader: http.Header{"Content-Type": []string{"application/json"}}, }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, + }, }, { - name: "Success: !hydrateCounts - OK", + name: "Error: ETAC User Does Not have Access To Specific Environment", buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ - Path: "/api/v2/enterprisecas/id", - RawQuery: "counts=false", + Path: "/api/v2/enterprisecas/id", }, Method: http.MethodGet, } }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.EnterpriseCA).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity}, + Properties: graph.AsProperties(map[string]any{ + "domainsid": "12345", + }), + }, nil) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, nil) + }, expected: expected{ - responseCode: http.StatusOK, - responseBody: `{"data":{"props":null, "kinds": []}}`, + responseCode: http.StatusForbidden, + responseBody: `{"errors":[{"context":"","message":"Forbidden"}],"http_status":403,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, responseHeader: http.Header{"Content-Type": []string{"application/json"}}, }, - setupMocks: func(t *testing.T, mocks *mock) { - t.Helper() - mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("EnterpriseCA")).Return(graph.NewNode(graph.ID(1), graph.NewProperties()), nil) + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, }, }, } @@ -1591,6 +2246,7 @@ func TestResources_GetEnterpriseCAEntityInfo(t *testing.T) { mocks := &mock{ mockGraphQuery: mocks.NewMockGraph(ctrl), + mockDatabase: mocks_db.NewMockDatabase(ctrl), } request := testCase.buildRequest() @@ -1607,6 +2263,7 @@ func TestResources_GetEnterpriseCAEntityInfo(t *testing.T) { resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DB: mocks.mockDatabase, DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } @@ -1630,6 +2287,7 @@ func TestResources_GetNTAuthStoreEntityInfo(t *testing.T) { type mock struct { mockGraphQuery *mocks.MockGraph + mockDatabase *mocks_db.MockDatabase } type expected struct { responseBody string @@ -1750,6 +2408,134 @@ func TestResources_GetNTAuthStoreEntityInfo(t *testing.T) { mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("NTAuthStore")).Return(graph.NewNode(graph.ID(1), graph.NewProperties()), nil) }, }, + { + name: "Success: ETAC enabled AllEnvironments", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/ntauthstores/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.NTAuthStore).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.NTAuthStore}, + Properties: props, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, ad.NTAuthStore), gomock.Any()).Return(map[string]any{"results": "output"}) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: true, + }, + }, + { + name: "Success: ETAC enabled For Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/ntauthstores/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.NTAuthStore).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity, ad.NTAuthStore}, + Properties: props, + }, nil).Times(2) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, graph.StringsToKinds([]string{ad.Entity.String(), ad.NTAuthStore.String()})...), gomock.Any()).Return(map[string]any{"results": "output"}) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, + }, + }, + { + name: "Error: ETAC User Does Not have Access To Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/ntauthstores/id", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.NTAuthStore).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity}, + Properties: graph.AsProperties(map[string]any{ + "domainsid": "12345", + }), + }, nil) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, nil) + }, + expected: expected{ + responseCode: http.StatusForbidden, + responseBody: `{"errors":[{"context":"","message":"Forbidden"}],"http_status":403,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, + }, + }, } for _, testCase := range tt { t.Run(testCase.name, func(t *testing.T) { @@ -1758,6 +2544,7 @@ func TestResources_GetNTAuthStoreEntityInfo(t *testing.T) { mocks := &mock{ mockGraphQuery: mocks.NewMockGraph(ctrl), + mockDatabase: mocks_db.NewMockDatabase(ctrl), } request := testCase.buildRequest() @@ -1774,6 +2561,7 @@ func TestResources_GetNTAuthStoreEntityInfo(t *testing.T) { resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DB: mocks.mockDatabase, DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } @@ -1797,6 +2585,7 @@ func TestResources_GetCertTemplateEntityInfo(t *testing.T) { type mock struct { mockGraphQuery *mocks.MockGraph + mockDatabase *mocks_db.MockDatabase } type expected struct { responseBody string @@ -1917,6 +2706,134 @@ func TestResources_GetCertTemplateEntityInfo(t *testing.T) { mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("CertTemplate")).Return(graph.NewNode(graph.ID(1), graph.NewProperties()), nil) }, }, + { + name: "Success: ETAC enabled AllEnvironments", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/certtemplates/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.CertTemplate).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.CertTemplate}, + Properties: props, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, ad.CertTemplate), gomock.Any()).Return(map[string]any{"results": "output"}) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: true, + }, + }, + { + name: "Success: ETAC enabled For Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/certtemplates/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.CertTemplate).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity, ad.CertTemplate}, + Properties: props, + }, nil).Times(2) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, graph.StringsToKinds([]string{ad.Entity.String(), ad.CertTemplate.String()})...), gomock.Any()).Return(map[string]any{"results": "output"}) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, + }, + }, + { + name: "Error: ETAC User Does Not have Access To Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/certtemplates/id", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.CertTemplate).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity}, + Properties: graph.AsProperties(map[string]any{ + "domainsid": "12345", + }), + }, nil) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, nil) + }, + expected: expected{ + responseCode: http.StatusForbidden, + responseBody: `{"errors":[{"context":"","message":"Forbidden"}],"http_status":403,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, + }, + }, } for _, testCase := range tt { t.Run(testCase.name, func(t *testing.T) { @@ -1925,6 +2842,7 @@ func TestResources_GetCertTemplateEntityInfo(t *testing.T) { mocks := &mock{ mockGraphQuery: mocks.NewMockGraph(ctrl), + mockDatabase: mocks_db.NewMockDatabase(ctrl), } request := testCase.buildRequest() @@ -1941,6 +2859,7 @@ func TestResources_GetCertTemplateEntityInfo(t *testing.T) { resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DB: mocks.mockDatabase, DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } @@ -1964,6 +2883,7 @@ func TestResources_GetIssuancePolicyEntityInfo(t *testing.T) { type mock struct { mockGraphQuery *mocks.MockGraph + mockDatabase *mocks_db.MockDatabase } type expected struct { responseBody string @@ -2084,6 +3004,134 @@ func TestResources_GetIssuancePolicyEntityInfo(t *testing.T) { mocks.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", graph.StringKind("IssuancePolicy")).Return(graph.NewNode(graph.ID(1), graph.NewProperties()), nil) }, }, + { + name: "Success: ETAC enabled AllEnvironments", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/issuancepolicies/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.IssuancePolicy).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.IssuancePolicy}, + Properties: props, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, ad.IssuancePolicy), gomock.Any()).Return(map[string]any{"results": "output"}) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: true, + }, + }, + { + name: "Success: ETAC enabled For Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/issuancepolicies/id", + RawQuery: "counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + props := graph.AsProperties(map[string]any{ + "domainsid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.IssuancePolicy).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity, ad.IssuancePolicy}, + Properties: props, + }, nil).Times(2) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, nil) + mock.mockGraphQuery.EXPECT().GetEntityCountResults(gomock.Any(), graph.NewNode(graph.ID(16), props, graph.StringsToKinds([]string{ad.Entity.String(), ad.IssuancePolicy.String()})...), gomock.Any()).Return(map[string]any{"results": "output"}) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"results":"output"}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, + }, + }, + { + name: "Error: ETAC User Does Not have Access To Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/issuancepolicies/id", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.IssuancePolicy).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity}, + Properties: graph.AsProperties(map[string]any{ + "domainsid": "12345", + }), + }, nil) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, nil) + }, + expected: expected{ + responseCode: http.StatusForbidden, + responseBody: `{"errors":[{"context":"","message":"Forbidden"}],"http_status":403,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, + }, + }, } for _, testCase := range tt { t.Run(testCase.name, func(t *testing.T) { @@ -2092,6 +3140,7 @@ func TestResources_GetIssuancePolicyEntityInfo(t *testing.T) { mocks := &mock{ mockGraphQuery: mocks.NewMockGraph(ctrl), + mockDatabase: mocks_db.NewMockDatabase(ctrl), } request := testCase.buildRequest() @@ -2108,6 +3157,7 @@ func TestResources_GetIssuancePolicyEntityInfo(t *testing.T) { resources := v2.Resources{ GraphQuery: mocks.mockGraphQuery, + DB: mocks.mockDatabase, DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } diff --git a/cmd/api/src/api/v2/ad_related_entity_test.go b/cmd/api/src/api/v2/ad_related_entity_test.go index 7b3ef1f9bdd..8222ffd1481 100644 --- a/cmd/api/src/api/v2/ad_related_entity_test.go +++ b/cmd/api/src/api/v2/ad_related_entity_test.go @@ -37,6 +37,8 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/queries/mocks" "github.com/specterops/bloodhound/cmd/api/src/services/dogtags" "github.com/specterops/bloodhound/cmd/api/src/utils/test" + "github.com/specterops/bloodhound/packages/go/graphschema/ad" + "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/ops" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -714,6 +716,125 @@ func TestResources_ListADIssuancePolicyLinkedCertTemplates(t *testing.T) { responseHeader: http.Header{"Content-Type": []string{"application/json"}}, }, }, + { + name: "Success: ETAC enabled AllEnvironments", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/issuancepolicies/id/linkedtemplates", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockDatabase.EXPECT().GetFlagByKey(gomock.Any(), "entity_panel_cache").Return(appcfg.FeatureFlag{Enabled: true}, nil) + mock.mockGraphQuery.EXPECT().GetADEntityQueryResult(gomock.Any(), gomock.Any(), true).Return("results", 1, nil) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"count":1,"limit":10,"skip":0,"data":"results"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: true, + }, + }, + { + name: "Success: ETAC enabled For Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/issuancepolicies/id/linkedtemplates", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.Entity).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity}, + Properties: graph.AsProperties(map[string]any{ + "domainsid": "12345", + }), + }, nil) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, nil) + mock.mockDatabase.EXPECT().GetFlagByKey(gomock.Any(), "entity_panel_cache").Return(appcfg.FeatureFlag{Enabled: true}, nil) + mock.mockGraphQuery.EXPECT().GetADEntityQueryResult(gomock.Any(), gomock.Any(), true).Return("results", 1, nil) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"count":1,"limit":10,"skip":0,"data":"results"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, + }, + }, + { + name: "Error: ETAC User Does Not have Access To Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/issuancepolicies/id/linkedtemplates", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", ad.Entity).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{ad.Entity}, + Properties: graph.AsProperties(map[string]any{ + "domainsid": "12345", + }), + }, nil) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, nil) + }, + expected: expected{ + responseCode: http.StatusForbidden, + responseBody: `{"errors":[{"context":"","message":"Forbidden"}],"http_status":403,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, + }, + }, } for _, testCase := range tt { t.Run(testCase.name, func(t *testing.T) { From 52dc4c3cfca64f4fb8a90c1e837af68efd98cd9e Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Mon, 23 Feb 2026 15:38:40 -0600 Subject: [PATCH 08/11] BED-7403 updated azure tests with etac --- cmd/api/src/api/v2/azure.go | 80 +++++- cmd/api/src/api/v2/azure_test.go | 410 +++++++++++++++++++++++++------ 2 files changed, 413 insertions(+), 77 deletions(-) diff --git a/cmd/api/src/api/v2/azure.go b/cmd/api/src/api/v2/azure.go index a34bc7fe53d..35269832f69 100644 --- a/cmd/api/src/api/v2/azure.go +++ b/cmd/api/src/api/v2/azure.go @@ -34,6 +34,7 @@ import ( "github.com/specterops/bloodhound/cmd/api/src/utils" "github.com/specterops/bloodhound/packages/go/analysis/azure" "github.com/specterops/bloodhound/packages/go/bhlog/attr" + azure_schema "github.com/specterops/bloodhound/packages/go/graphschema/azure" "github.com/specterops/dawgs/graph" "github.com/specterops/dawgs/ops" ) @@ -424,15 +425,18 @@ func (s *Resources) GetAZEntity(response http.ResponseWriter, request *http.Requ if objectID := queryVars.Get(objectIDQueryParameterName); objectID == "" { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf("query parameter %s is required", objectIDQueryParameterName), request), response) - } else if relatedEntityTypeStr := queryVars.Get(relatedEntityTypeQueryParameterName); relatedEntityTypeStr != "" { - s.GetAZRelatedEntities(request.Context(), response, request, objectID) - } else if includeCounts, err := api.ParseOptionalBool(queryVars.Get(api.QueryParameterIncludeCounts), true); err != nil { + } else if azKind, err := azEntityParamToKind(entityType); err != nil { + slog.WarnContext(request.Context(), "could not determine AZ type from entityType request var", "type", entityType, "err", err) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsBadQueryParameterFilters, request), response) - } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, objectID, graph.StringKind(entityType)); err != nil { + } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, objectID, azKind); err != nil { slog.ErrorContext(request.Context(), "Error checking if user has access to node for ETAC", attr.Error(err)) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, api.ErrorResponseDetailsInternalServerError, request), response) } else if !hasAccess { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) + } else if relatedEntityTypeStr := queryVars.Get(relatedEntityTypeQueryParameterName); relatedEntityTypeStr != "" { + s.GetAZRelatedEntities(request.Context(), response, request, objectID) + } else if includeCounts, err := api.ParseOptionalBool(queryVars.Get(api.QueryParameterIncludeCounts), true); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsBadQueryParameterFilters, request), response) } else if entityInformation, err := GetAZEntityInformation(request.Context(), s.Graph, entityType, objectID, includeCounts); err != nil { if graph.IsErrNotFound(err) { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusNotFound, "not found", request), response) @@ -443,3 +447,71 @@ func (s *Resources) GetAZEntity(response http.ResponseWriter, request *http.Requ api.WriteBasicResponse(request.Context(), entityInformation, http.StatusOK, response) } } + +// azEntityParamToKind takes a string which is parsed from a user's request params and converts it to a known Azure kind +// For example: `az-base` becomes the Kind `AZBase` +func azEntityParamToKind(entityType string) (graph.Kind, error) { + switch entityType { + case entityTypeBase: + return azure_schema.Entity, nil + case entityTypeUsers: + return azure_schema.User, nil + + case entityTypeGroups: + return azure_schema.Group, nil + + case entityTypeTenants: + return azure_schema.Tenant, nil + + case entityTypeManagementGroups: + return azure_schema.ManagementGroup, nil + + case entityTypeSubscriptions: + return azure_schema.Subscription, nil + + case entityTypeResourceGroups: + return azure_schema.ResourceGroup, nil + + case entityTypeVMs: + return azure_schema.VM, nil + + case entityTypeManagedClusters: + return azure_schema.ManagedCluster, nil + + case entityTypeContainerRegistries: + return azure_schema.ContainerRegistry, nil + + case entityTypeWebApps: + return azure_schema.WebApp, nil + + case entityTypeLogicApps: + return azure_schema.LogicApp, nil + + case entityTypeAutomationAccounts: + return azure_schema.AutomationAccount, nil + + case entityTypeKeyVaults: + return azure_schema.KeyVault, nil + + case entityTypeDevices: + return azure_schema.Device, nil + + case entityTypeApplications: + return azure_schema.App, nil + + case entityTypeVMScaleSets: + return azure_schema.VMScaleSet, nil + + case entityTypeServicePrincipals: + return azure_schema.ServicePrincipal, nil + + case entityTypeRoles: + return azure_schema.Role, nil + + case entityTypeFunctionApps: + return azure_schema.FunctionApp, nil + + default: + return nil, fmt.Errorf("unknown azure entity %s", entityType) + } +} diff --git a/cmd/api/src/api/v2/azure_test.go b/cmd/api/src/api/v2/azure_test.go index 6cb0f8956ec..d5832d5b51a 100644 --- a/cmd/api/src/api/v2/azure_test.go +++ b/cmd/api/src/api/v2/azure_test.go @@ -27,9 +27,12 @@ import ( "github.com/gorilla/mux" "github.com/specterops/bloodhound/cmd/api/src/auth" "github.com/specterops/bloodhound/cmd/api/src/ctx" + mocks_db "github.com/specterops/bloodhound/cmd/api/src/database/mocks" "github.com/specterops/bloodhound/cmd/api/src/model" + mocks_graph "github.com/specterops/bloodhound/cmd/api/src/queries/mocks" "github.com/specterops/bloodhound/cmd/api/src/services/dogtags" "github.com/specterops/bloodhound/packages/go/analysis/azure" + azure_schema "github.com/specterops/bloodhound/packages/go/graphschema/azure" graphmocks "github.com/specterops/bloodhound/cmd/api/src/vendormocks/dawgs/graph" "github.com/specterops/dawgs/graph" @@ -46,7 +49,9 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { t.Parallel() type mock struct { - mockDB *graphmocks.MockDatabase + mockDatabase *mocks_db.MockDatabase + mockGraphDB *graphmocks.MockDatabase + mockGraphQuery *mocks_graph.MockGraph } type expected struct { responseBody string @@ -87,7 +92,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ - Path: "/api/v2/azure/invalid", + Path: "/api/v2/azure/roles", RawQuery: "type=bad&object_id=id&related_entity_type=list", }, Method: http.MethodGet, @@ -105,7 +110,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ - Path: "/api/v2/azure/type", + Path: "/api/v2/azure/roles", RawQuery: "object_id=id&related_entity_type=list&skip=true", }, Method: http.MethodGet, @@ -123,7 +128,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ - Path: "/api/v2/azure/type", + Path: "/api/v2/azure/roles", RawQuery: "object_id=id&related_entity_type=list&skip=1&limit=true", }, Method: http.MethodGet, @@ -141,7 +146,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ - Path: "/api/v2/azure/type", + Path: "/api/v2/azure/roles", RawQuery: "object_id=id&type=graph&skip=0&limit=1&related_entity_type=inbound-control", }, Method: http.MethodGet, @@ -149,7 +154,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(v2.ErrParameterSkip) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(v2.ErrParameterSkip) }, expected: expected{ responseCode: http.StatusInternalServerError, @@ -162,7 +167,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ - Path: "/api/v2/azure/{entity_type}", + Path: "/api/v2/azure/roles", RawQuery: "object_id=id&type=graph&skip=0&limit=1&related_entity_type=inbound-control", }, Method: http.MethodGet, @@ -170,7 +175,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, expected: expected{ responseCode: http.StatusOK, @@ -183,7 +188,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ - Path: "/api/v2/azure/{entity_type}", + Path: "/api/v2/azure/roles", RawQuery: "object_id=id&related_entity_type=descendent-users&skip=0&limit=1", }, Method: http.MethodGet, @@ -191,7 +196,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(v2.ErrParameterSkip) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(v2.ErrParameterSkip) }, expected: expected{ responseCode: http.StatusBadRequest, @@ -204,7 +209,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ - Path: "/api/v2/azure/{entity_type}", + Path: "/api/v2/azure/roles", RawQuery: "object_id=id&related_entity_type=descendent-users&skip=0&limit=1", }, Method: http.MethodGet, @@ -212,7 +217,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(v2.ErrParameterRelatedEntityType) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(v2.ErrParameterRelatedEntityType) }, expected: expected{ responseCode: http.StatusNotFound, @@ -225,7 +230,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ - Path: "/api/v2/azure/{entity_type}", + Path: "/api/v2/azure/roles", RawQuery: "object_id=id&related_entity_type=descendent-users&skip=0&limit=1", }, Method: http.MethodGet, @@ -233,7 +238,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(ops.ErrGraphQueryMemoryLimit) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(ops.ErrGraphQueryMemoryLimit) }, expected: expected{ responseCode: http.StatusInternalServerError, @@ -246,7 +251,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ - Path: "/api/v2/azure/{entity_type}", + Path: "/api/v2/azure/roles", RawQuery: "object_id=id&related_entity_type=descendent-users&skip=0&limit=1", }, Method: http.MethodGet, @@ -254,7 +259,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, expected: expected{ responseCode: http.StatusInternalServerError, @@ -267,7 +272,7 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ - Path: "/api/v2/azure/{entity_type}", + Path: "/api/v2/azure/roles", RawQuery: "object_id=id&related_entity_type=inbound-control&skip=0&limit=1", }, Method: http.MethodGet, @@ -275,13 +280,136 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"count":0,"limit":1,"skip":0,"data":[]}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + }, + { + name: "Success: ETAC enabled AllEnvironments", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/azure/roles", + RawQuery: "object_id=id&related_entity_type=inbound-control&skip=0&limit=1", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"count":0,"limit":1,"skip":0,"data":[]}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: true, + }, + }, + { + name: "Success: ETAC enabled For Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/azure/roles", + RawQuery: "object_id=id&related_entity_type=inbound-control&skip=0&limit=1", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + + props := graph.AsProperties(map[string]any{ + "tenantid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", azure_schema.Role).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{azure_schema.Entity, azure_schema.Role}, + Properties: props, + }, nil) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, nil) }, expected: expected{ responseCode: http.StatusOK, responseBody: `{"count":0,"limit":1,"skip":0,"data":[]}`, responseHeader: http.Header{"Content-Type": []string{"application/json"}}, }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, + }, + }, + { + name: "Error: ETAC User Does Not have Access To Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/azure/roles", + RawQuery: "object_id=id&related_entity_type=inbound-control&skip=0&limit=1", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", azure_schema.Role).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{azure_schema.Entity, azure_schema.Role}, + Properties: graph.AsProperties(map[string]any{ + "tenantid": "12345", + }), + }, nil) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, nil) + }, + expected: expected{ + responseCode: http.StatusForbidden, + responseBody: `{"errors":[{"context":"","message":"Forbidden"}],"http_status":403,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, + }, }, } for _, testCase := range tt { @@ -290,7 +418,9 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { ctrl := gomock.NewController(t) mocks := &mock{ - mockDB: graphmocks.NewMockDatabase(ctrl), + mockDatabase: mocks_db.NewMockDatabase(ctrl), + mockGraphDB: graphmocks.NewMockDatabase(ctrl), + mockGraphQuery: mocks_graph.NewMockGraph(ctrl), } request := testCase.buildRequest() @@ -306,8 +436,10 @@ func TestResources_GetAZRelatedEntities(t *testing.T) { testCase.setupMocks(t, mocks) resources := v2.Resources{ - Graph: mocks.mockDB, - DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), + Graph: mocks.mockGraphDB, + GraphQuery: mocks.mockGraphQuery, + DB: mocks.mockDatabase, + DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } response := httptest.NewRecorder() @@ -329,7 +461,9 @@ func TestResources_GetAZEntityInformation(t *testing.T) { t.Parallel() type mock struct { - mockDB *graphmocks.MockDatabase + mockDatabase *mocks_db.MockDatabase + mockGraphDB *graphmocks.MockDatabase + mockGraphQuery *mocks_graph.MockGraph } type args struct { entityType string @@ -353,7 +487,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -367,7 +501,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.BaseDetails(azure.BaseDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, OutboundObjectControl: 0}), @@ -381,7 +515,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -395,7 +529,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.UserDetails(azure.UserDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, GroupMembership: 0, Roles: 0, ExecutionPrivileges: 0, OutboundObjectControl: 0, InboundObjectControl: 0}), @@ -409,7 +543,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -423,7 +557,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.GroupDetails(azure.GroupDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, Roles: 0, GroupMembers: 0, GroupMembership: 0, OutboundObjectControl: 0, InboundObjectControl: 0}), @@ -437,7 +571,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -451,7 +585,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.TenantDetails(azure.TenantDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, Descendents: azure.Descendents{DescendentCounts: map[string]int(nil)}, InboundObjectControl: 0}), @@ -465,7 +599,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -479,7 +613,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.ManagementGroupDetails(azure.ManagementGroupDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, Descendents: azure.Descendents{DescendentCounts: map[string]int(nil)}, InboundObjectControl: 0}), @@ -493,7 +627,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -507,7 +641,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.SubscriptionDetails(azure.SubscriptionDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, Descendents: azure.Descendents{DescendentCounts: map[string]int(nil)}, InboundObjectControl: 0}), @@ -521,7 +655,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -535,7 +669,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.ResourceGroupDetails(azure.ResourceGroupDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, Descendents: azure.Descendents{DescendentCounts: map[string]int(nil)}, InboundObjectControl: 0}), @@ -549,7 +683,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -563,7 +697,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.VMDetails(azure.VMDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, InboundExecutionPrivileges: 0, InboundObjectControl: 0}), @@ -577,7 +711,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -591,7 +725,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.ManagedClusterDetails(azure.ManagedClusterDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, InboundObjectControl: 0}), @@ -605,7 +739,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -619,7 +753,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.ContainerRegistryDetails(azure.ContainerRegistryDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, InboundObjectControl: 0}), @@ -633,7 +767,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -647,7 +781,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.WebAppDetails(azure.WebAppDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, InboundObjectControl: 0}), @@ -661,7 +795,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -675,7 +809,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.LogicAppDetails(azure.LogicAppDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, InboundObjectControl: 0}), @@ -689,7 +823,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -703,7 +837,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.AutomationAccountDetails(azure.AutomationAccountDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, InboundObjectControl: 0}), @@ -717,7 +851,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -731,7 +865,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.KeyVaultDetails(azure.KeyVaultDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, Readers: azure.KeyVaultReaderCounts{KeyReaders: 0, CertificateReaders: 0, SecretReaders: 0, AllReaders: 0}, InboundObjectControl: 0}), @@ -745,7 +879,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -759,7 +893,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.DeviceDetails(azure.DeviceDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, InboundExecutionPrivileges: 0, InboundObjectControl: 0}), @@ -773,7 +907,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -787,7 +921,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.ApplicationDetails(azure.ApplicationDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, InboundObjectControl: 0}), @@ -801,7 +935,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -815,7 +949,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.VMScaleSetDetails(azure.VMScaleSetDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, InboundObjectControl: 0}), @@ -829,7 +963,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -843,7 +977,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.ServicePrincipalDetails(azure.ServicePrincipalDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, Roles: 0, InboundObjectControl: 0, OutboundObjectControl: 0, InboundAbusableAppRoleAssignments: 0, OutboundAbusableAppRoleAssignments: 0}), @@ -857,7 +991,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -871,7 +1005,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.RoleDetails(azure.RoleDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, ActiveAssignments: 0, PIMAssignments: 0}), @@ -885,7 +1019,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(errors.New("error")) }, want: want{ res: nil, @@ -899,7 +1033,7 @@ func TestResources_GetAZEntityInformation(t *testing.T) { }, setupMocks: func(t *testing.T, mocks *mock) { t.Helper() - mocks.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mocks.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) }, want: want{ res: azure.FunctionAppDetails(azure.FunctionAppDetails{Node: azure.Node{Kind: "", Properties: map[string]interface{}(nil)}, InboundObjectControl: 0}), @@ -924,12 +1058,14 @@ func TestResources_GetAZEntityInformation(t *testing.T) { ctrl := gomock.NewController(t) mocks := &mock{ - mockDB: graphmocks.NewMockDatabase(ctrl), + mockDatabase: mocks_db.NewMockDatabase(ctrl), + mockGraphDB: graphmocks.NewMockDatabase(ctrl), + mockGraphQuery: mocks_graph.NewMockGraph(ctrl), } testCase.setupMocks(t, mocks) - res, err := v2.GetAZEntityInformation(context.Background(), mocks.mockDB, testCase.args.entityType, "id", false) + res, err := v2.GetAZEntityInformation(context.Background(), mocks.mockGraphDB, testCase.args.entityType, "id", false) if err != nil && testCase.want.err != nil { require.Equal(t, testCase.want.err, err) @@ -944,7 +1080,9 @@ func TestManagementResource_GetAZEntity(t *testing.T) { t.Parallel() type mock struct { - mockDB *graphmocks.MockDatabase + mockDatabase *mocks_db.MockDatabase + mockGraphDB *graphmocks.MockDatabase + mockGraphQuery *mocks_graph.MockGraph } type expected struct { responseBody string @@ -984,7 +1122,7 @@ func TestManagementResource_GetAZEntity(t *testing.T) { buildRequest: func() *http.Request { return &http.Request{ URL: &url.URL{ - Path: "/api/v2/azure/{entity_type}", + Path: "/api/v2/azure/roles", RawQuery: "object_id=id&related_entity_type=bad", }, Method: http.MethodGet, @@ -1028,7 +1166,7 @@ func TestManagementResource_GetAZEntity(t *testing.T) { }, setupMocks: func(t *testing.T, mock *mock) { t.Helper() - mock.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(graph.ErrNoResultsFound) + mock.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(graph.ErrNoResultsFound) }, expected: expected{ responseCode: http.StatusNotFound, @@ -1049,8 +1187,8 @@ func TestManagementResource_GetAZEntity(t *testing.T) { }, setupMocks: func(t *testing.T, mock *mock) {}, expected: expected{ - responseCode: http.StatusInternalServerError, - responseBody: `{"errors":[{"context":"","message":"db error: unknown azure entity unknown"}],"http_status":500,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseCode: http.StatusBadRequest, + responseBody: `{"errors":[{"context":"","message":"there are errors in the query parameter filters specified"}],"http_status":400,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, responseHeader: http.Header{"Content-Type": []string{"application/json"}}, }, }, @@ -1067,13 +1205,135 @@ func TestManagementResource_GetAZEntity(t *testing.T) { }, setupMocks: func(t *testing.T, mock *mock) { t.Helper() - mock.mockDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + mock.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"isOwnedObject":false, "isTierZero":false, "kind":"","props":null,"active_assignments":0,"approvers":0, "kinds":null, "pim_assignments":0}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + }, + { + name: "Success: ETAC enabled AllEnvironments", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/azure/roles", + RawQuery: "object_id=id&counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + }, + expected: expected{ + responseCode: http.StatusOK, + responseBody: `{"data":{"isOwnedObject":false, "isTierZero":false, "kind":"","props":null,"active_assignments":0,"approvers":0, "kinds":null, "pim_assignments":0}}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: true, + }, + }, + { + name: "Success: ETAC enabled For Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/azure/roles", + RawQuery: "object_id=id&counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphDB.EXPECT().ReadTransaction(gomock.Any(), gomock.Any()).Return(nil) + + props := graph.AsProperties(map[string]any{ + "tenantid": "12345", + }) + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", azure_schema.Role).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{azure_schema.Entity, azure_schema.Role}, + Properties: props, + }, nil) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, nil) }, expected: expected{ responseCode: http.StatusOK, responseBody: `{"data":{"isOwnedObject":false, "isTierZero":false, "kind":"","props":null,"active_assignments":0,"approvers":0, "kinds":null, "pim_assignments":0}}`, responseHeader: http.Header{"Content-Type": []string{"application/json"}}, }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "12345", + }, + }, + }, + }, + { + name: "Error: ETAC User Does Not have Access To Specific Environment", + buildRequest: func() *http.Request { + return &http.Request{ + URL: &url.URL{ + Path: "/api/v2/azure/roles", + RawQuery: "object_id=id&counts=true", + }, + Method: http.MethodGet, + } + }, + setupMocks: func(t *testing.T, mock *mock) { + t.Helper() + mock.mockGraphQuery.EXPECT().GetEntityByObjectId(gomock.Any(), "id", azure_schema.Role).Return(&graph.Node{ + ID: graph.ID(16), + Kinds: graph.Kinds{azure_schema.Entity, azure_schema.Role}, + Properties: graph.AsProperties(map[string]any{ + "tenantid": "12345", + }), + }, nil) + mock.mockDatabase.EXPECT().GetEnvironmentTargetedAccessControlForUser(gomock.Any(), gomock.Any()).Return([]model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, nil) + }, + expected: expected{ + responseCode: http.StatusForbidden, + responseBody: `{"errors":[{"context":"","message":"Forbidden"}],"http_status":403,"request_id":"","timestamp":"0001-01-01T00:00:00Z"}`, + responseHeader: http.Header{"Content-Type": []string{"application/json"}}, + }, + dogTagsOverrides: dogtags.TestOverrides{ + Bools: map[dogtags.BoolDogTag]bool{ + dogtags.ETAC_ENABLED: true, + }, + }, + user: model.User{ + AllEnvironments: false, + EnvironmentTargetedAccessControl: []model.EnvironmentTargetedAccessControl{ + { + EnvironmentID: "54321", + }, + }, + }, }, } for _, testCase := range tt { @@ -1082,7 +1342,9 @@ func TestManagementResource_GetAZEntity(t *testing.T) { ctrl := gomock.NewController(t) mocks := &mock{ - mockDB: graphmocks.NewMockDatabase(ctrl), + mockDatabase: mocks_db.NewMockDatabase(ctrl), + mockGraphDB: graphmocks.NewMockDatabase(ctrl), + mockGraphQuery: mocks_graph.NewMockGraph(ctrl), } request := testCase.buildRequest() @@ -1098,8 +1360,10 @@ func TestManagementResource_GetAZEntity(t *testing.T) { testCase.setupMocks(t, mocks) resources := v2.Resources{ - Graph: mocks.mockDB, - DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), + Graph: mocks.mockGraphDB, + GraphQuery: mocks.mockGraphQuery, + DB: mocks.mockDatabase, + DogTags: dogtags.NewTestService(testCase.dogTagsOverrides), } response := httptest.NewRecorder() From c0d63c5cd6b9a4c4264c2f417cd28b6a4934595f Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Mon, 23 Feb 2026 15:43:34 -0600 Subject: [PATCH 09/11] BED-7403 naming --- cmd/api/src/api/v2/azure.go | 2 +- cmd/api/src/api/v2/etac.go | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/api/src/api/v2/azure.go b/cmd/api/src/api/v2/azure.go index 35269832f69..31a482ed296 100644 --- a/cmd/api/src/api/v2/azure.go +++ b/cmd/api/src/api/v2/azure.go @@ -426,7 +426,7 @@ func (s *Resources) GetAZEntity(response http.ResponseWriter, request *http.Requ if objectID := queryVars.Get(objectIDQueryParameterName); objectID == "" { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf("query parameter %s is required", objectIDQueryParameterName), request), response) } else if azKind, err := azEntityParamToKind(entityType); err != nil { - slog.WarnContext(request.Context(), "could not determine AZ type from entityType request var", "type", entityType, "err", err) + slog.WarnContext(request.Context(), "Could not determine AZ type from entityType request var", "type", entityType, "err", err) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsBadQueryParameterFilters, request), response) } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, objectID, azKind); err != nil { slog.ErrorContext(request.Context(), "Error checking if user has access to node for ETAC", attr.Error(err)) diff --git a/cmd/api/src/api/v2/etac.go b/cmd/api/src/api/v2/etac.go index 854e4c461c5..75ed9b72c95 100644 --- a/cmd/api/src/api/v2/etac.go +++ b/cmd/api/src/api/v2/etac.go @@ -75,17 +75,17 @@ func CheckUserHasAccessToNodeById(ctx context.Context, db database.Database, gra } var environmentId string - if tenantID := node.Kinds.ContainsOneOf(azure.Entity); tenantID { - if id, err := node.Properties.Get(azure.TenantID.String()).String(); err != nil { + if node.Kinds.ContainsOneOf(azure.Entity) { + if tenantId, err := node.Properties.Get(azure.TenantID.String()).String(); err != nil { return false, err } else { - environmentId = id + environmentId = tenantId } - } else if domainSID := node.Kinds.ContainsOneOf(ad.Entity); domainSID { - if id, err := node.Properties.Get(ad.DomainSID.String()).String(); err != nil { + } else if node.Kinds.ContainsOneOf(ad.Entity) { + if domainSid, err := node.Properties.Get(ad.DomainSID.String()).String(); err != nil { return false, err } else { - environmentId = id + environmentId = domainSid } } From ecae846975d8e84e676b09a9830c169d74567e52 Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Mon, 23 Feb 2026 15:52:50 -0600 Subject: [PATCH 10/11] BED-7403 slog lint --- cmd/api/src/api/v2/azure.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/api/src/api/v2/azure.go b/cmd/api/src/api/v2/azure.go index 31a482ed296..61d86d815e2 100644 --- a/cmd/api/src/api/v2/azure.go +++ b/cmd/api/src/api/v2/azure.go @@ -426,7 +426,7 @@ func (s *Resources) GetAZEntity(response http.ResponseWriter, request *http.Requ if objectID := queryVars.Get(objectIDQueryParameterName); objectID == "" { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf("query parameter %s is required", objectIDQueryParameterName), request), response) } else if azKind, err := azEntityParamToKind(entityType); err != nil { - slog.WarnContext(request.Context(), "Could not determine AZ type from entityType request var", "type", entityType, "err", err) + slog.WarnContext(request.Context(), "Could not determine AZ type from entityType request var", slog.String("type", entityType), attr.Error(err)) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsBadQueryParameterFilters, request), response) } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, objectID, azKind); err != nil { slog.ErrorContext(request.Context(), "Error checking if user has access to node for ETAC", attr.Error(err)) From 58ff792dd05b37d3dba44c41c8a42b40052bb296 Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Mon, 23 Feb 2026 15:58:48 -0600 Subject: [PATCH 11/11] BED-7403 slog lint --- cmd/api/src/api/v2/azure.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/api/src/api/v2/azure.go b/cmd/api/src/api/v2/azure.go index 61d86d815e2..40415ee58b9 100644 --- a/cmd/api/src/api/v2/azure.go +++ b/cmd/api/src/api/v2/azure.go @@ -426,7 +426,9 @@ func (s *Resources) GetAZEntity(response http.ResponseWriter, request *http.Requ if objectID := queryVars.Get(objectIDQueryParameterName); objectID == "" { api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, fmt.Sprintf("query parameter %s is required", objectIDQueryParameterName), request), response) } else if azKind, err := azEntityParamToKind(entityType); err != nil { - slog.WarnContext(request.Context(), "Could not determine AZ type from entityType request var", slog.String("type", entityType), attr.Error(err)) + slog.WarnContext(request.Context(), "Could not determine AZ type from entityType request var", + slog.String("entityType", entityType), + attr.Error(err)) api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsBadQueryParameterFilters, request), response) } else if hasAccess, err := CheckUserHasAccessToNodeById(request.Context(), s.DB, s.GraphQuery, s.DogTags, user, objectID, azKind); err != nil { slog.ErrorContext(request.Context(), "Error checking if user has access to node for ETAC", attr.Error(err))