diff --git a/cmd/api/src/api/registration/v2.go b/cmd/api/src/api/registration/v2.go index a04efb6b11..fc0124f620 100644 --- a/cmd/api/src/api/registration/v2.go +++ b/cmd/api/src/api/registration/v2.go @@ -165,6 +165,7 @@ func NewV2API(cfg config.Configuration, resources v2.Resources, routerInst *rout routerInst.POST("/api/v2/saved-queries", resources.CreateSavedQuery).RequirePermissions(permissions.SavedQueriesWrite), routerInst.PUT(fmt.Sprintf("/api/v2/saved-queries/{%s}", api.URIPathVariableSavedQueryID), resources.UpdateSavedQuery).RequirePermissions(permissions.SavedQueriesWrite), routerInst.DELETE(fmt.Sprintf("/api/v2/saved-queries/{%s}", api.URIPathVariableSavedQueryID), resources.DeleteSavedQuery).RequirePermissions(permissions.SavedQueriesWrite), + routerInst.PUT(fmt.Sprintf("/api/v2/saved-queries/{%s}/share", api.URIPathVariableSavedQueryID), resources.ShareSavedQueries).RequirePermissions(permissions.SavedQueriesWrite), // Azure Entity API routerInst.GET("/api/v2/azure/{entity_type}", resources.GetAZEntity).RequirePermissions(permissions.GraphDBRead), diff --git a/cmd/api/src/api/v2/saved_queries.go b/cmd/api/src/api/v2/saved_queries.go index 5a42c820c5..7a7d7418eb 100644 --- a/cmd/api/src/api/v2/saved_queries.go +++ b/cmd/api/src/api/v2/saved_queries.go @@ -23,6 +23,7 @@ import ( "strconv" "strings" + "github.com/gofrs/uuid" "github.com/gorilla/mux" "github.com/specterops/bloodhound/src/api" "github.com/specterops/bloodhound/src/auth" @@ -256,3 +257,234 @@ func (s Resources) DeleteSavedQuery(response http.ResponseWriter, request *http. } } + +type ShareSavedQueriesResponse []model.SavedQueriesPermissions + +type SavedQueryPermissionRequest struct { + UserIDs []uuid.UUID `json:"user_ids"` + Public bool `json:"public"` +} + +// ShareSavedQueries allows a user to share queries between users, as well as share them publicly +func (s Resources) ShareSavedQueries(response http.ResponseWriter, request *http.Request) { + var ( + rawSavedQueryID = mux.Vars(request)[api.URIPathVariableSavedQueryID] + createRequest SavedQueryPermissionRequest + ) + + if user, isUser := auth.GetUserFromAuthCtx(ctx2.FromRequest(request).AuthCtx); !isUser { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "No associated user found", request), response) + } else if savedQueryID, err := strconv.ParseInt(rawSavedQueryID, 10, 64); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, api.ErrorResponseDetailsIDMalformed, request), response) + } else if err := api.ReadJSONRequestPayloadLimited(&createRequest, request); err != nil { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response) + } else if createRequest.Public && len(createRequest.UserIDs) > 0 { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "Public cannot be true while user_ids is populated", request), response) + } else if savedQueryBelongsToUser, err := s.DB.SavedQueryBelongsToUser(request.Context(), user.ID, savedQueryID); errors.Is(err, database.ErrNotFound) { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusNotFound, "Query does not exist", request), response) + } else if err != nil { + api.HandleDatabaseError(request, response, err) + } else if dbSavedQueryScope, err := s.DB.GetScopeForSavedQuery(request.Context(), int64(savedQueryID), user.ID); err != nil { + api.HandleDatabaseError(request, response, err) + } else if isSavedQueryShared, err := s.DB.IsSavedQueryShared(request.Context(), int64(savedQueryID)); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + isAdmin := user.Roles.Has(model.Role{Name: auth.RoleAdministrator}) + + if isAdmin { + // Query set to public + if createRequest.Public { + if savedQueryBelongsToUser { + if dbSavedQueryScope[model.SavedQueryScopePublic] { + response.WriteHeader(http.StatusNoContent) + } else { + if isSavedQueryShared { + if savedPermission, err := s.DB.CreateSavedQueryPermissionToPublic(request.Context(), int64(savedQueryID)); err != nil { + api.HandleDatabaseError(request, response, err) + } else if savedQueryPermissions, err := s.DB.GetPermissionsForSavedQuery(request.Context(), int64(savedQueryID)); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + for _, permission := range savedQueryPermissions { + sharedToUserID := permission.SharedToUserID + + if err := s.DB.DeleteSavedQueryPermissionsForUser(request.Context(), int64(savedQueryID), sharedToUserID.UUID); err != nil { + api.HandleDatabaseError(request, response, err) + } + } + api.WriteBasicResponse(request.Context(), ShareSavedQueriesResponse{savedPermission}, http.StatusCreated, response) + } + } else { + if savedPermission, err := s.DB.CreateSavedQueryPermissionToPublic(request.Context(), int64(savedQueryID)); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + api.WriteBasicResponse(request.Context(), ShareSavedQueriesResponse{savedPermission}, http.StatusCreated, response) + } + } + } + } else { + if dbSavedQueryScope[model.SavedQueryScopePublic] { + response.WriteHeader(http.StatusNoContent) + } else { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) + return + } + } + // Query set to private + } else if len(createRequest.UserIDs) == 0 { + if savedQueryBelongsToUser { + if dbSavedQueryScope[model.SavedQueryScopePublic] { + if err := s.DB.DeleteSavedQueryPermissionPublic(request.Context(), int64(savedQueryID)); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + response.WriteHeader(http.StatusNoContent) + } + } else { + if isSavedQueryShared { + if savedQueryPermissions, err := s.DB.GetPermissionsForSavedQuery(request.Context(), int64(savedQueryID)); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + for _, permission := range savedQueryPermissions { + sharedToUserID := permission.SharedToUserID + + if err := s.DB.DeleteSavedQueryPermissionsForUser(request.Context(), int64(savedQueryID), sharedToUserID.UUID); err != nil { + api.HandleDatabaseError(request, response, err) + } + } + response.WriteHeader(http.StatusNoContent) + } + } else { + response.WriteHeader(http.StatusNoContent) + } + } + } else { + if dbSavedQueryScope[model.SavedQueryScopePublic] { + if err := s.DB.DeleteSavedQueryPermissionPublic(request.Context(), int64(savedQueryID)); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + response.WriteHeader(http.StatusNoContent) + } + } else { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) + return + } + } + // Sharing a query + } else if len(createRequest.UserIDs) > 0 && !createRequest.Public { + if savedQueryBelongsToUser { + if dbSavedQueryScope[model.SavedQueryScopePublic] { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "Public query cannot be shared to users. You must set your query to private first", request), response) + } else { + var newPermissions []model.SavedQueriesPermissions + for _, sharedUserID := range createRequest.UserIDs { + if sharedUserID != user.ID { + newPermissions = append(newPermissions, model.SavedQueriesPermissions{ + QueryID: int64(savedQueryID), + Public: false, + SharedToUserID: database.NullUUID(sharedUserID), + }) + } else { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "Cannot share query to self", request), response) + return + } + } + // Save the permissions to the database + if savedPermissions, err := s.DB.CreateSavedQueryPermissionsBatch(request.Context(), newPermissions); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + api.WriteBasicResponse(request.Context(), savedPermissions, http.StatusCreated, response) + } + } + } else { + if dbSavedQueryScope[model.SavedQueryScopePublic] { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "Public query cannot be shared to users. You must set your query to private first", request), response) + } else { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) + return + } + } + } + } else if !isAdmin { + if !savedQueryBelongsToUser { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) + return + // Query set to public + } else if createRequest.Public { + if dbSavedQueryScope[model.SavedQueryScopePublic] { + response.WriteHeader(http.StatusNoContent) + } else { + if isSavedQueryShared { + if savedPermission, err := s.DB.CreateSavedQueryPermissionToPublic(request.Context(), int64(savedQueryID)); err != nil { + api.HandleDatabaseError(request, response, err) + } else if savedQueryPermissions, err := s.DB.GetPermissionsForSavedQuery(request.Context(), int64(savedQueryID)); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + for _, permission := range savedQueryPermissions { + sharedToUserID := permission.SharedToUserID + + if err := s.DB.DeleteSavedQueryPermissionsForUser(request.Context(), int64(savedQueryID), sharedToUserID.UUID); err != nil { + api.HandleDatabaseError(request, response, err) + } + } + api.WriteBasicResponse(request.Context(), ShareSavedQueriesResponse{savedPermission}, http.StatusCreated, response) + } + } else { + if savedPermission, err := s.DB.CreateSavedQueryPermissionToPublic(request.Context(), int64(savedQueryID)); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + api.WriteBasicResponse(request.Context(), ShareSavedQueriesResponse{savedPermission}, http.StatusCreated, response) + } + } + } + // Query set to private + } else if len(createRequest.UserIDs) == 0 { + if dbSavedQueryScope[model.SavedQueryScopePublic] { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) + return + } else { + if isSavedQueryShared { + if savedQueryPermissions, err := s.DB.GetPermissionsForSavedQuery(request.Context(), int64(savedQueryID)); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + for _, permission := range savedQueryPermissions { + sharedToUserID := permission.SharedToUserID + + if err := s.DB.DeleteSavedQueryPermissionsForUser(request.Context(), int64(savedQueryID), sharedToUserID.UUID); err != nil { + api.HandleDatabaseError(request, response, err) + } + } + response.WriteHeader(http.StatusNoContent) + } + } else { + response.WriteHeader(http.StatusNoContent) + } + } + // Sharing a query + } else if len(createRequest.UserIDs) > 0 && !createRequest.Public { + if dbSavedQueryScope[model.SavedQueryScopePublic] { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusForbidden, api.ErrorResponseDetailsForbidden, request), response) + return + } else { + var newPermissions []model.SavedQueriesPermissions + for _, sharedUserID := range createRequest.UserIDs { + if sharedUserID != user.ID { + newPermissions = append(newPermissions, model.SavedQueriesPermissions{ + QueryID: int64(savedQueryID), + Public: false, + SharedToUserID: database.NullUUID(sharedUserID), + }) + } else { + api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "Cannot share query to self", request), response) + return + } + } + // Save the permissions to the database + if savedPermissions, err := s.DB.CreateSavedQueryPermissionsBatch(request.Context(), newPermissions); err != nil { + api.HandleDatabaseError(request, response, err) + } else { + api.WriteBasicResponse(request.Context(), savedPermissions, http.StatusCreated, response) + } + } + } + } + } +} diff --git a/cmd/api/src/api/v2/saved_queries_test.go b/cmd/api/src/api/v2/saved_queries_test.go index 3d69dabdae..760ed502cd 100644 --- a/cmd/api/src/api/v2/saved_queries_test.go +++ b/cmd/api/src/api/v2/saved_queries_test.go @@ -18,12 +18,15 @@ package v2_test import ( "context" + "database/sql" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "net/url" "testing" + "time" uuid2 "github.com/gofrs/uuid" "github.com/gorilla/mux" @@ -1506,6 +1509,7 @@ func createContextWithAdminOwnerId(id uuid2.UUID) context.Context { Roles: []model.Role{{ Name: auth.RoleAdministrator, Description: "Can manage users, clients, and application configuration", + Permissions: auth.Permissions().All(), }}, }, }, @@ -1513,3 +1517,1783 @@ func createContextWithAdminOwnerId(id uuid2.UUID) context.Context { } return bhCtx.ConstructGoContext() } + +func TestResources_ShareSavedQueries_SavingPermissionsErrors(t *testing.T) { + var ( + mockCtrl = gomock.NewController(t) + mockDB = mocks.NewMockDatabase(mockCtrl) + resources = v2.Resources{DB: mockDB} + ) + defer mockCtrl.Finish() + + endpoint := "/api/v2/saved-queries/%s/share" + savedQueryId := "1" + userId, err := uuid2.NewV4() + require.Nil(t, err) + userId2, err := uuid2.NewV4() + require.Nil(t, err) + userId3, err := uuid2.NewV4() + require.Nil(t, err) + + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{userId2, userId3}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: false, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().CreateSavedQueryPermissionsBatch(gomock.Any(), []model.SavedQueriesPermissions{ + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId2), + }, + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId3), + }, + }).Return(nil, fmt.Errorf("Error!")) + + req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusInternalServerError, response.Code) +} + +func TestResources_SharedSavedQueries_NonAdmin(t *testing.T) { + + var ( + mockCtrl = gomock.NewController(t) + mockDB = mocks.NewMockDatabase(mockCtrl) + resources = v2.Resources{DB: mockDB} + ) + + defer mockCtrl.Finish() + + endpoint := "/api/v2/saved-queries/%s/share" + savedQueryId := "1" + userId, err := uuid2.NewV4() + require.Nil(t, err) + userId2, err := uuid2.NewV4() + require.Nil(t, err) + userId3, err := uuid2.NewV4() + require.Nil(t, err) + + t.Run("Query doesn't belong to user error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{userId}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: false, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusForbidden, response.Code) + }) + + t.Run("Query shared to self error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{userId}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusBadRequest, response.Code) + require.Contains(t, response.Body.String(), "Cannot share query to self") + }) + + t.Run("Query set to public and shared to user(s) at same time error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{userId2}, + Public: true, + } + + req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + + router.ServeHTTP(response, req) + require.Equal(t, http.StatusBadRequest, response.Code) + require.Contains(t, response.Body.String(), "Public cannot be true while user_ids is populated") + }) + + t.Run("Shared query shared to user(s) (and user(s) that already have the query shared with them) success", func(t *testing.T) { + // Request made in order to share to a user for confirming 2nd request below + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{userId2}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().CreateSavedQueryPermissionsBatch(gomock.Any(), []model.SavedQueriesPermissions{ + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId2), + }, + }).Return([]model.SavedQueriesPermissions{ + { + ID: 1, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId2), + }, + }, nil) + + req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusCreated, response.Code) + + // Request that we actually care about passing + payload2 := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{userId2, userId3}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().CreateSavedQueryPermissionsBatch(gomock.Any(), []model.SavedQueriesPermissions{ + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId2), + }, + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId3), + }, + }).Return([]model.SavedQueriesPermissions{ + { + ID: 1, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId2), + }, + { + ID: 2, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId3), + }, + }, nil) + + req2, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload2)) + require.Nil(t, err) + req2.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + response2 := httptest.NewRecorder() + // Using the same router as the first request + router.ServeHTTP(response2, req2) + require.Equal(t, http.StatusCreated, response2.Code) + + bodyBytes2, err := io.ReadAll(response2.Body) + require.Nil(t, err) + + var temp2 struct { + Data v2.ShareSavedQueriesResponse `json:"data"` + } + err = json.Unmarshal(bodyBytes2, &temp2) + require.Nil(t, err) + + parsedTime, err := time.Parse(time.RFC3339, "0001-01-01T00:00:00Z") + require.Nil(t, err) + + require.Equal(t, v2.ShareSavedQueriesResponse{ + { + ID: 1, + SharedToUserID: database.NullUUID(userId2), + QueryID: 1, + Public: false, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + { + ID: 2, + SharedToUserID: database.NullUUID(userId3), + QueryID: 1, + Public: false, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + }, temp2.Data) + }) + + t.Run("Shared query shared to user(s) success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{userId2}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().CreateSavedQueryPermissionsBatch(gomock.Any(), []model.SavedQueriesPermissions{ + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId2), + }, + }).Return([]model.SavedQueriesPermissions{ + { + ID: 1, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId2), + }, + }, nil) + + req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusCreated, response.Code) + + bodyBytes, err := io.ReadAll(response.Body) + require.Nil(t, err) + + var temp struct { + Data v2.ShareSavedQueriesResponse `json:"data"` + } + err = json.Unmarshal(bodyBytes, &temp) + require.Nil(t, err) + + parsedTime, err := time.Parse(time.RFC3339, "0001-01-01T00:00:00Z") + require.Nil(t, err) + + require.Equal(t, v2.ShareSavedQueriesResponse{ + { + ID: 1, + SharedToUserID: database.NullUUID(userId2), + QueryID: 1, + Public: false, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + }, temp.Data) + }) + + t.Run("Shared query set to public success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: true, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().CreateSavedQueryPermissionToPublic(gomock.Any(), int64(1)).Return(model.SavedQueriesPermissions{ + ID: 1, + QueryID: 1, + SharedToUserID: uuid2.NullUUID{ + UUID: uuid2.UUID{}, + Valid: false, + }, + Public: true, + }, nil) + mockDB.EXPECT().GetPermissionsForSavedQuery(gomock.Any(), gomock.Any()).Return([]model.SavedQueriesPermissions{ + { + ID: 1, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId2), + }, + { + ID: 2, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId3), + }, + }, nil) + mockDB.EXPECT().DeleteSavedQueryPermissionsForUser(gomock.Any(), gomock.Any(), userId2).Return(nil) + mockDB.EXPECT().DeleteSavedQueryPermissionsForUser(gomock.Any(), gomock.Any(), userId3).Return(nil) + + req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusCreated, response.Code) + + bodyBytes, err := io.ReadAll(response.Body) + require.Nil(t, err) + + var temp struct { + Data v2.ShareSavedQueriesResponse `json:"data"` + } + err = json.Unmarshal(bodyBytes, &temp) + require.Nil(t, err) + + parsedTime, err := time.Parse(time.RFC3339, "0001-01-01T00:00:00Z") + require.Nil(t, err) + + require.Equal(t, v2.ShareSavedQueriesResponse{ + { + ID: 1, + SharedToUserID: uuid2.NullUUID{ + UUID: uuid2.UUID{}, + Valid: false, + }, + QueryID: 1, + Public: true, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + }, temp.Data) + }) + + t.Run("Shared query set to private success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetPermissionsForSavedQuery(gomock.Any(), gomock.Any()).Return([]model.SavedQueriesPermissions{ + { + ID: 1, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId2), + }, + { + ID: 2, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId3), + }, + }, nil) + + mockDB.EXPECT().DeleteSavedQueryPermissionsForUser(gomock.Any(), gomock.Any(), userId2).Return(nil) + mockDB.EXPECT().DeleteSavedQueryPermissionsForUser(gomock.Any(), gomock.Any(), userId3).Return(nil) + + req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + + require.Equal(t, http.StatusNoContent, response.Code) + }) + + t.Run("Private query shared to user(s) success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{userId2, userId3}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().CreateSavedQueryPermissionsBatch(gomock.Any(), []model.SavedQueriesPermissions{ + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId2), + }, + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId3), + }, + }).Return([]model.SavedQueriesPermissions{ + { + ID: 1, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId2), + }, + { + ID: 2, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(userId3), + }, + }, nil) + + req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusCreated, response.Code) + + bodyBytes, err := io.ReadAll(response.Body) + require.Nil(t, err) + + var temp struct { + Data v2.ShareSavedQueriesResponse `json:"data"` + } + err = json.Unmarshal(bodyBytes, &temp) + require.Nil(t, err) + + parsedTime, err := time.Parse(time.RFC3339, "0001-01-01T00:00:00Z") + require.Nil(t, err) + + require.Equal(t, v2.ShareSavedQueriesResponse{ + { + ID: 1, + SharedToUserID: database.NullUUID(userId2), + QueryID: 1, + Public: false, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + { + ID: 2, + SharedToUserID: database.NullUUID(userId3), + QueryID: 1, + Public: false, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + }, temp.Data) + }) + + t.Run("Private query set to public success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: true, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().CreateSavedQueryPermissionToPublic(gomock.Any(), int64(1)).Return(model.SavedQueriesPermissions{ + ID: 1, + QueryID: 1, + SharedToUserID: uuid2.NullUUID{ + UUID: uuid2.UUID{}, + Valid: false, + }, + Public: true, + }, nil) + + req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusCreated, response.Code) + + bodyBytes, err := io.ReadAll(response.Body) + require.Nil(t, err) + + var temp struct { + Data v2.ShareSavedQueriesResponse `json:"data"` + } + err = json.Unmarshal(bodyBytes, &temp) + require.Nil(t, err) + + parsedTime, err := time.Parse(time.RFC3339, "0001-01-01T00:00:00Z") + require.Nil(t, err) + + require.Equal(t, v2.ShareSavedQueriesResponse{ + { + ID: 1, + SharedToUserID: uuid2.NullUUID{ + UUID: uuid2.UUID{}, + Valid: false, + }, + QueryID: 1, + Public: true, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + }, temp.Data) + }) + + t.Run("Private query set to private success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusNoContent, response.Code) + }) + + t.Run("Public query shared to user(s) error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{userId2, userId3}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: true, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusForbidden, response.Code) + }) + + t.Run("Public query set to private error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: true, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusForbidden, response.Code) + }) + + t.Run("Public query set to public error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: true, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: true, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusNoContent, response.Code) + }) +} + +func TestResources_SharedSavedQueries_Admin(t *testing.T) { + + var ( + mockCtrl = gomock.NewController(t) + mockDB = mocks.NewMockDatabase(mockCtrl) + resources = v2.Resources{DB: mockDB} + ) + + defer mockCtrl.Finish() + + endpoint := "/api/v2/saved-queries/%s/share" + savedQueryId := "1" + adminUserId, err := uuid2.NewV4() + require.Nil(t, err) + nonAdminUserId, err := uuid2.NewV4() + require.Nil(t, err) + nonAdminUserId2, err := uuid2.NewV4() + require.Nil(t, err) + + t.Run("Query set to public and shared to user(s) at same time error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{nonAdminUserId}, + Public: true, + } + + // createContextWithAdminOwnerId + // createContextWithOwnerIdAsAdmin + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + + router.ServeHTTP(response, req) + require.Equal(t, http.StatusBadRequest, response.Code) + require.Contains(t, response.Body.String(), "Public cannot be true while user_ids is populated") + }) + + t.Run("Admin, public query shared to user(s) incorrectly error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{nonAdminUserId, nonAdminUserId2}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: false, + model.SavedQueryScopePublic: true, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusBadRequest, response.Code) + require.Contains(t, response.Body.String(), "Public query cannot be shared to users. You must set your query to private first") + }) + + t.Run("Admin, private query set to public error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: true, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: false, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusForbidden, response.Code) + + }) + + t.Run("Admin, private query shared to user(s) error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{nonAdminUserId}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: false, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusForbidden, response.Code) + + }) + + t.Run("Admin, private query set to private error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: false, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusForbidden, response.Code) + }) + + t.Run("Admin, shared query set to public error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: true, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: false, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(true, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusForbidden, response.Code) + }) + + t.Run("Admin, shared query shared to user(s) error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{nonAdminUserId, nonAdminUserId2}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: false, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(true, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusForbidden, response.Code) + }) + + t.Run("Admin, shared query set to priavte error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: false, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(true, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusForbidden, response.Code) + }) + + t.Run("Admin, public query set to public success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: true, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: false, + model.SavedQueryScopePublic: true, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusNoContent, response.Code) + }) + + t.Run("Admin, public query set to private success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: false, + model.SavedQueryScopePublic: true, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().DeleteSavedQueryPermissionPublic(gomock.Any(), gomock.Any()).Return(nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusNoContent, response.Code) + }) + + // Test cases where admin is making operations against their own query + t.Run("Admin owned, query shared to self error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{adminUserId}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + + router.ServeHTTP(response, req) + require.Equal(t, http.StatusBadRequest, response.Code) + require.Contains(t, response.Body.String(), "Cannot share query to self") + }) + + t.Run("Admin owned, shared query shared to user(s) (and user(s) that already have the query shared with them) success", func(t *testing.T) { + // Request made in order to share to a user for confirming 2nd request below + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{nonAdminUserId}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().CreateSavedQueryPermissionsBatch(gomock.Any(), []model.SavedQueriesPermissions{ + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId), + }, + }).Return([]model.SavedQueriesPermissions{ + { + ID: 1, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId), + }, + }, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusCreated, response.Code) + + // Request that we actually care about passing + payload2 := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{nonAdminUserId, nonAdminUserId2}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().CreateSavedQueryPermissionsBatch(gomock.Any(), []model.SavedQueriesPermissions{ + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId), + }, + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId2), + }, + }).Return([]model.SavedQueriesPermissions{ + { + ID: 1, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId), + }, + { + ID: 2, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId2), + }, + }, nil) + + req2, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload2)) + require.Nil(t, err) + req2.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + response2 := httptest.NewRecorder() + // Using the same router as the first request + router.ServeHTTP(response2, req2) + require.Equal(t, http.StatusCreated, response2.Code) + + bodyBytes2, err := io.ReadAll(response2.Body) + require.Nil(t, err) + + var temp2 struct { + Data v2.ShareSavedQueriesResponse `json:"data"` + } + err = json.Unmarshal(bodyBytes2, &temp2) + require.Nil(t, err) + + parsedTime, err := time.Parse(time.RFC3339, "0001-01-01T00:00:00Z") + require.Nil(t, err) + + require.Equal(t, v2.ShareSavedQueriesResponse{ + { + ID: 1, + SharedToUserID: database.NullUUID(nonAdminUserId), + QueryID: 1, + Public: false, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + { + ID: 2, + SharedToUserID: database.NullUUID(nonAdminUserId2), + QueryID: 1, + Public: false, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + }, temp2.Data) + }) + + t.Run("Admin owned, shared query shared to user(s) success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{nonAdminUserId}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().CreateSavedQueryPermissionsBatch(gomock.Any(), []model.SavedQueriesPermissions{ + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId), + }, + }).Return([]model.SavedQueriesPermissions{ + { + ID: 1, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId), + }, + }, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusCreated, response.Code) + + bodyBytes, err := io.ReadAll(response.Body) + require.Nil(t, err) + + var temp struct { + Data v2.ShareSavedQueriesResponse `json:"data"` + } + err = json.Unmarshal(bodyBytes, &temp) + require.Nil(t, err) + + parsedTime, err := time.Parse(time.RFC3339, "0001-01-01T00:00:00Z") + require.Nil(t, err) + + require.Equal(t, v2.ShareSavedQueriesResponse{ + { + ID: 1, + SharedToUserID: database.NullUUID(nonAdminUserId), + QueryID: 1, + Public: false, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + }, temp.Data) + }) + + t.Run("Admin owned, shared query set to public success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: true, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().CreateSavedQueryPermissionToPublic(gomock.Any(), int64(1)).Return(model.SavedQueriesPermissions{ + ID: 1, + QueryID: 1, + SharedToUserID: uuid2.NullUUID{ + UUID: uuid2.UUID{}, + Valid: false, + }, + Public: true, + }, nil) + mockDB.EXPECT().GetPermissionsForSavedQuery(gomock.Any(), gomock.Any()).Return([]model.SavedQueriesPermissions{ + { + ID: 1, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId), + }, + { + ID: 2, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId2), + }, + }, nil) + mockDB.EXPECT().DeleteSavedQueryPermissionsForUser(gomock.Any(), gomock.Any(), nonAdminUserId).Return(nil) + mockDB.EXPECT().DeleteSavedQueryPermissionsForUser(gomock.Any(), gomock.Any(), nonAdminUserId2).Return(nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusCreated, response.Code) + + bodyBytes, err := io.ReadAll(response.Body) + require.Nil(t, err) + + var temp struct { + Data v2.ShareSavedQueriesResponse `json:"data"` + } + err = json.Unmarshal(bodyBytes, &temp) + require.Nil(t, err) + + parsedTime, err := time.Parse(time.RFC3339, "0001-01-01T00:00:00Z") + require.Nil(t, err) + + require.Equal(t, v2.ShareSavedQueriesResponse{ + { + ID: 1, + SharedToUserID: uuid2.NullUUID{ + UUID: uuid2.UUID{}, + Valid: false, + }, + QueryID: 1, + Public: true, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + }, temp.Data) + }) + + t.Run("Admin owned, shared query set to private success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetPermissionsForSavedQuery(gomock.Any(), gomock.Any()).Return([]model.SavedQueriesPermissions{ + { + ID: 1, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId), + }, + { + ID: 2, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId2), + }, + }, nil) + + mockDB.EXPECT().DeleteSavedQueryPermissionsForUser(gomock.Any(), gomock.Any(), nonAdminUserId).Return(nil) + mockDB.EXPECT().DeleteSavedQueryPermissionsForUser(gomock.Any(), gomock.Any(), nonAdminUserId2).Return(nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + + require.Equal(t, http.StatusNoContent, response.Code) + }) + + t.Run("Admin owned, private query shared to user(s) success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{nonAdminUserId, nonAdminUserId2}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().CreateSavedQueryPermissionsBatch(gomock.Any(), []model.SavedQueriesPermissions{ + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId), + }, + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId2), + }, + }).Return([]model.SavedQueriesPermissions{ + { + ID: 1, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId), + }, + { + ID: 2, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId2), + }, + }, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusCreated, response.Code) + + bodyBytes, err := io.ReadAll(response.Body) + require.Nil(t, err) + + var temp struct { + Data v2.ShareSavedQueriesResponse `json:"data"` + } + err = json.Unmarshal(bodyBytes, &temp) + require.Nil(t, err) + + parsedTime, err := time.Parse(time.RFC3339, "0001-01-01T00:00:00Z") + require.Nil(t, err) + + require.Equal(t, v2.ShareSavedQueriesResponse{ + { + ID: 1, + SharedToUserID: database.NullUUID(nonAdminUserId), + QueryID: 1, + Public: false, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + { + ID: 2, + SharedToUserID: database.NullUUID(nonAdminUserId2), + QueryID: 1, + Public: false, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + }, temp.Data) + }) + + t.Run("Admin owned, private query set to public success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: true, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().CreateSavedQueryPermissionToPublic(gomock.Any(), int64(1)).Return(model.SavedQueriesPermissions{ + ID: 1, + QueryID: 1, + SharedToUserID: uuid2.NullUUID{ + UUID: uuid2.UUID{}, + Valid: false, + }, + Public: true, + }, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusCreated, response.Code) + + bodyBytes, err := io.ReadAll(response.Body) + require.Nil(t, err) + + var temp struct { + Data v2.ShareSavedQueriesResponse `json:"data"` + } + err = json.Unmarshal(bodyBytes, &temp) + require.Nil(t, err) + + parsedTime, err := time.Parse(time.RFC3339, "0001-01-01T00:00:00Z") + require.Nil(t, err) + + require.Equal(t, v2.ShareSavedQueriesResponse{ + { + ID: 1, + SharedToUserID: uuid2.NullUUID{ + UUID: uuid2.UUID{}, + Valid: false, + }, + QueryID: 1, + Public: true, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + }, temp.Data) + }) + + t.Run("Admin owned, private query set to private success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusNoContent, response.Code) + }) + + t.Run("Admin owned, public query set to public success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: true, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: true, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusNoContent, response.Code) + }) + + t.Run("Admin owned, public query shared to user(s) success", func(t *testing.T) { + // First have public query set to private + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: true, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().DeleteSavedQueryPermissionPublic(gomock.Any(), gomock.Any()).Return(nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusNoContent, response.Code) + + // Now have private query share to users + payload2 := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{nonAdminUserId, nonAdminUserId2}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: false, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().CreateSavedQueryPermissionsBatch(gomock.Any(), []model.SavedQueriesPermissions{ + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId), + }, + { + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId2), + }, + }).Return([]model.SavedQueriesPermissions{ + { + ID: 1, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId), + }, + { + ID: 2, + QueryID: int64(1), + Public: false, + SharedToUserID: database.NullUUID(nonAdminUserId2), + }, + }, nil) + + req2, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload2)) + require.Nil(t, err) + req2.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + response2 := httptest.NewRecorder() + // Using the same router as the first request + router.ServeHTTP(response2, req2) + require.Equal(t, http.StatusCreated, response2.Code) + + bodyBytes2, err := io.ReadAll(response2.Body) + require.Nil(t, err) + + var temp2 struct { + Data v2.ShareSavedQueriesResponse `json:"data"` + } + err = json.Unmarshal(bodyBytes2, &temp2) + require.Nil(t, err) + + parsedTime, err := time.Parse(time.RFC3339, "0001-01-01T00:00:00Z") + require.Nil(t, err) + + require.Equal(t, v2.ShareSavedQueriesResponse{ + { + ID: 1, + SharedToUserID: database.NullUUID(nonAdminUserId), + QueryID: 1, + Public: false, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + { + ID: 2, + SharedToUserID: database.NullUUID(nonAdminUserId2), + QueryID: 1, + Public: false, + Basic: model.Basic{ + CreatedAt: parsedTime, + UpdatedAt: parsedTime, + DeletedAt: sql.NullTime{ + Time: parsedTime, + Valid: false, + }, + }, + }, + }, temp2.Data) + }) + + t.Run("Admin owned, public query set to private success", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: true, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + mockDB.EXPECT().DeleteSavedQueryPermissionPublic(gomock.Any(), gomock.Any()).Return(nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusNoContent, response.Code) + }) + + t.Run("Admin owned, public query shared to user(s) incorrectly error", func(t *testing.T) { + payload := v2.SavedQueryPermissionRequest{ + UserIDs: []uuid2.UUID{nonAdminUserId, nonAdminUserId2}, + Public: false, + } + + mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil) + mockDB.EXPECT().GetScopeForSavedQuery(gomock.Any(), gomock.Any(), gomock.Any()).Return(database.SavedQueryScopeMap{ + model.SavedQueryScopeOwned: true, + model.SavedQueryScopePublic: true, + model.SavedQueryScopeShared: false, + }, nil) + mockDB.EXPECT().IsSavedQueryShared(gomock.Any(), gomock.Any()).Return(false, nil) + + req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(adminUserId), "PUT", fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) + require.Nil(t, err) + req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) + + router := mux.NewRouter() + router.HandleFunc("/api/v2/saved-queries/{saved_query_id}/share", resources.ShareSavedQueries).Methods("PUT") + + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + require.Equal(t, http.StatusBadRequest, response.Code) + require.Contains(t, response.Body.String(), "Public query cannot be shared to users. You must set your query to private first") + }) +} diff --git a/cmd/api/src/database/mocks/db.go b/cmd/api/src/database/mocks/db.go index 5cf7ae1544..b06b537165 100644 --- a/cmd/api/src/database/mocks/db.go +++ b/cmd/api/src/database/mocks/db.go @@ -26,6 +26,7 @@ import ( time "time" uuid "github.com/gofrs/uuid" + database "github.com/specterops/bloodhound/src/database" model "github.com/specterops/bloodhound/src/model" appcfg "github.com/specterops/bloodhound/src/model/appcfg" gomock "go.uber.org/mock/gomock" @@ -347,6 +348,21 @@ func (mr *MockDatabaseMockRecorder) CreateSavedQueryPermissionToUser(arg0, arg1, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSavedQueryPermissionToUser", reflect.TypeOf((*MockDatabase)(nil).CreateSavedQueryPermissionToUser), arg0, arg1, arg2) } +// CreateSavedQueryPermissionsBatch mocks base method. +func (m *MockDatabase) CreateSavedQueryPermissionsBatch(arg0 context.Context, arg1 []model.SavedQueriesPermissions) ([]model.SavedQueriesPermissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateSavedQueryPermissionsBatch", arg0, arg1) + ret0, _ := ret[0].([]model.SavedQueriesPermissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateSavedQueryPermissionsBatch indicates an expected call of CreateSavedQueryPermissionsBatch. +func (mr *MockDatabaseMockRecorder) CreateSavedQueryPermissionsBatch(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateSavedQueryPermissionsBatch", reflect.TypeOf((*MockDatabase)(nil).CreateSavedQueryPermissionsBatch), arg0, arg1) +} + // CreateUser mocks base method. func (m *MockDatabase) CreateUser(arg0 context.Context, arg1 model.User) (model.User, error) { m.ctrl.T.Helper() @@ -545,6 +561,34 @@ func (mr *MockDatabaseMockRecorder) DeleteSavedQuery(arg0, arg1 interface{}) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSavedQuery", reflect.TypeOf((*MockDatabase)(nil).DeleteSavedQuery), arg0, arg1) } +// DeleteSavedQueryPermissionPublic mocks base method. +func (m *MockDatabase) DeleteSavedQueryPermissionPublic(arg0 context.Context, arg1 int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteSavedQueryPermissionPublic", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteSavedQueryPermissionPublic indicates an expected call of DeleteSavedQueryPermissionPublic. +func (mr *MockDatabaseMockRecorder) DeleteSavedQueryPermissionPublic(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSavedQueryPermissionPublic", reflect.TypeOf((*MockDatabase)(nil).DeleteSavedQueryPermissionPublic), arg0, arg1) +} + +// DeleteSavedQueryPermissionsForUser mocks base method. +func (m *MockDatabase) DeleteSavedQueryPermissionsForUser(arg0 context.Context, arg1 int64, arg2 uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteSavedQueryPermissionsForUser", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteSavedQueryPermissionsForUser indicates an expected call of DeleteSavedQueryPermissionsForUser. +func (mr *MockDatabaseMockRecorder) DeleteSavedQueryPermissionsForUser(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSavedQueryPermissionsForUser", reflect.TypeOf((*MockDatabase)(nil).DeleteSavedQueryPermissionsForUser), arg0, arg1, arg2) +} + // DeleteUser mocks base method. func (m *MockDatabase) DeleteUser(arg0 context.Context, arg1 model.User) error { m.ctrl.T.Helper() @@ -1027,10 +1071,10 @@ func (mr *MockDatabaseMockRecorder) GetPermission(arg0, arg1 interface{}) *gomoc } // GetPermissionsForSavedQuery mocks base method. -func (m *MockDatabase) GetPermissionsForSavedQuery(arg0 context.Context, arg1 int64) (model.SavedQueriesPermissions, error) { +func (m *MockDatabase) GetPermissionsForSavedQuery(arg0 context.Context, arg1 int64) ([]model.SavedQueriesPermissions, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetPermissionsForSavedQuery", arg0, arg1) - ret0, _ := ret[0].(model.SavedQueriesPermissions) + ret0, _ := ret[0].([]model.SavedQueriesPermissions) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1131,6 +1175,21 @@ func (mr *MockDatabaseMockRecorder) GetSavedQuery(arg0, arg1 interface{}) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSavedQuery", reflect.TypeOf((*MockDatabase)(nil).GetSavedQuery), arg0, arg1) } +// GetScopeForSavedQuery mocks base method. +func (m *MockDatabase) GetScopeForSavedQuery(arg0 context.Context, arg1 int64, arg2 uuid.UUID) (database.SavedQueryScopeMap, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetScopeForSavedQuery", arg0, arg1, arg2) + ret0, _ := ret[0].(database.SavedQueryScopeMap) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetScopeForSavedQuery indicates an expected call of GetScopeForSavedQuery. +func (mr *MockDatabaseMockRecorder) GetScopeForSavedQuery(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetScopeForSavedQuery", reflect.TypeOf((*MockDatabase)(nil).GetScopeForSavedQuery), arg0, arg1, arg2) +} + // GetSharedSavedQueries mocks base method. func (m *MockDatabase) GetSharedSavedQueries(arg0 context.Context, arg1 uuid.UUID) (model.SavedQueries, error) { m.ctrl.T.Helper() @@ -1279,6 +1338,21 @@ func (mr *MockDatabaseMockRecorder) IsSavedQueryPublic(arg0, arg1 interface{}) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsSavedQueryPublic", reflect.TypeOf((*MockDatabase)(nil).IsSavedQueryPublic), arg0, arg1) } +// IsSavedQueryShared mocks base method. +func (m *MockDatabase) IsSavedQueryShared(arg0 context.Context, arg1 int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsSavedQueryShared", arg0, arg1) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsSavedQueryShared indicates an expected call of IsSavedQueryShared. +func (mr *MockDatabaseMockRecorder) IsSavedQueryShared(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsSavedQueryShared", reflect.TypeOf((*MockDatabase)(nil).IsSavedQueryShared), arg0, arg1) +} + // ListAuditLogs mocks base method. func (m *MockDatabase) ListAuditLogs(arg0 context.Context, arg1, arg2 time.Time, arg3, arg4 int, arg5 string, arg6 model.SQLFilter) (model.AuditLogs, int, error) { m.ctrl.T.Helper() diff --git a/cmd/api/src/database/saved_queries_permissions.go b/cmd/api/src/database/saved_queries_permissions.go index e61bf6c660..840ef569ec 100644 --- a/cmd/api/src/database/saved_queries_permissions.go +++ b/cmd/api/src/database/saved_queries_permissions.go @@ -21,16 +21,26 @@ import ( "github.com/gofrs/uuid" "github.com/specterops/bloodhound/src/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" ) // SavedQueriesPermissionsData methods representing the database interactions pertaining to the saved_queries_permissions model type SavedQueriesPermissionsData interface { CreateSavedQueryPermissionToUser(ctx context.Context, queryID int64, userID uuid.UUID) (model.SavedQueriesPermissions, error) CreateSavedQueryPermissionToPublic(ctx context.Context, queryID int64) (model.SavedQueriesPermissions, error) + CreateSavedQueryPermissionsBatch(ctx context.Context, savedQueryPermissions []model.SavedQueriesPermissions) ([]model.SavedQueriesPermissions, error) CheckUserHasPermissionToSavedQuery(ctx context.Context, queryID int64, userID uuid.UUID) (bool, error) - GetPermissionsForSavedQuery(ctx context.Context, queryID int64) (model.SavedQueriesPermissions, error) + GetPermissionsForSavedQuery(ctx context.Context, queryID int64) ([]model.SavedQueriesPermissions, error) + GetScopeForSavedQuery(ctx context.Context, queryID int64, userID uuid.UUID) (SavedQueryScopeMap, error) + DeleteSavedQueryPermissionPublic(ctx context.Context, queryID int64) error + DeleteSavedQueryPermissionsForUser(ctx context.Context, queryID int64, userID uuid.UUID) error + IsSavedQueryShared(ctx context.Context, queryID int64) (bool, error) } +// SavedQueryScopeMap holds the information of a saved query's scope [IE: owned, shared, public] +type SavedQueryScopeMap map[model.SavedQueryScope]bool + // CreateSavedQueryPermissionToUser creates a new entry to the SavedQueriesPermissions table granting a provided user id to access a provided query func (s *BloodhoundDB) CreateSavedQueryPermissionToUser(ctx context.Context, queryID int64, userID uuid.UUID) (model.SavedQueriesPermissions, error) { permission := model.SavedQueriesPermissions{ @@ -49,19 +59,97 @@ func (s *BloodhoundDB) CreateSavedQueryPermissionToPublic(ctx context.Context, q Public: true, } - return permission, CheckError(s.db.WithContext(ctx).Create(&permission)) + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := CheckError(tx.Create(&permission)); err != nil { + return err + } else if err := CheckError(tx.Table("saved_queries_permissions").Where("query_id = ? AND public = false", queryID).Delete(&model.SavedQueriesPermissions{})); err != nil { + return err + } + + return nil + }) + + return permission, err +} + +// CreateSavedQueryPermissionsBatch attempts to save the given saved query permissions in batches of 100 in a transaction +func (s *BloodhoundDB) CreateSavedQueryPermissionsBatch(ctx context.Context, savedQueryPermissions []model.SavedQueriesPermissions) ([]model.SavedQueriesPermissions, error) { + result := s.db.WithContext(ctx).Clauses(clause.OnConflict{ + DoNothing: true, + }).CreateInBatches(&savedQueryPermissions, 100) + + return savedQueryPermissions, CheckError(result) } // CheckUserHasPermissionToSavedQuery returns true or false depending on if the given userID has permission to read the given queryID func (s *BloodhoundDB) CheckUserHasPermissionToSavedQuery(ctx context.Context, queryID int64, userID uuid.UUID) (bool, error) { - result := s.db.WithContext(ctx).Where("query_id = ? AND user_id = ?", queryID, userID).Or("query_id = ? AND public = true", queryID).Limit(1) + rows := int64(0) + result := s.db.WithContext(ctx).Table("saved_queries_permissions").Select("*").Where("query_id = ? AND shared_to_user_id = ?", queryID, userID).Or("query_id = ? AND public = true", queryID).Limit(1).Count(&rows) - return result.RowsAffected > 0, CheckError(result) + return rows > 0, CheckError(result) } // GetPermissionsForSavedQuery gets all permissions associated with the provided query ID -func (s *BloodhoundDB) GetPermissionsForSavedQuery(ctx context.Context, queryID int64) (model.SavedQueriesPermissions, error) { - queryPermissions := model.SavedQueriesPermissions{QueryID: queryID} +func (s *BloodhoundDB) GetPermissionsForSavedQuery(ctx context.Context, queryID int64) ([]model.SavedQueriesPermissions, error) { + queryPermissions := make([]model.SavedQueriesPermissions, 0) result := s.db.WithContext(ctx).Where("query_id = ?", queryID).Find(&queryPermissions) + return queryPermissions, CheckError(result) } + +// GetScopeForSavedQuery will return a map of the possible scopes given a query id and a user id +func (s *BloodhoundDB) GetScopeForSavedQuery(ctx context.Context, queryID int64, userID uuid.UUID) (SavedQueryScopeMap, error) { + scopes := SavedQueryScopeMap{ + model.SavedQueryScopePublic: false, + model.SavedQueryScopeOwned: false, + model.SavedQueryScopeShared: false, + } + + // Check if the query was shared with the user publicly + publicCount := int64(0) + if result := s.db.WithContext(ctx).Select("*").Table("saved_queries_permissions").Where("public = true AND query_id = ?", queryID).Count(&publicCount).Limit(1); result.Error != nil { + return scopes, CheckError(result) + } else if publicCount > 0 { + scopes[model.SavedQueryScopePublic] = true + } + + // Check if the user owns the query + ownedCount := int64(0) + if result := s.db.WithContext(ctx).Select("*").Table("saved_queries").Where("id = ? AND user_id = ?", queryID, userID).Count(&ownedCount).Limit(1); result.Error != nil { + return scopes, CheckError(result) + } else if ownedCount > 0 { + scopes[model.SavedQueryScopeOwned] = true + } + + // Check if the user has had the query shared to them + sharedCount := int64(0) + if result := s.db.WithContext(ctx).Select("*").Table("saved_queries_permissions").Where("query_id = ? AND shared_to_user_id = ?", queryID, userID).Count(&sharedCount).Limit(1); result.Error != nil { + return scopes, CheckError(result) + } else if sharedCount > 0 { + scopes[model.SavedQueryScopeShared] = true + } + + return scopes, nil +} + +// DeleteSavedQueryPermissionPublic deletes the saved query permission associated with the passed in query id and public = true +func (s *BloodhoundDB) DeleteSavedQueryPermissionPublic(ctx context.Context, queryID int64) error { + return CheckError(s.db.WithContext(ctx).Table("saved_queries_permissions").Where("public = true AND query_id = ?", queryID).Delete(&model.SavedQueriesPermissions{})) +} + +// DeleteSavedQueryPermissionsForUser deletes all permissions associated with the passed in query id and user id +func (s *BloodhoundDB) DeleteSavedQueryPermissionsForUser(ctx context.Context, queryID int64, userID uuid.UUID) error { + return CheckError(s.db.WithContext(ctx).Table("saved_queries_permissions").Where("query_id = ? AND shared_to_user_id = ?", queryID, userID).Delete(&model.SavedQueriesPermissions{})) +} + +// IsSavedQueryShared returns true or false depending on if the saved query is being shared with users +func (s *BloodhoundDB) IsSavedQueryShared(ctx context.Context, queryID int64) (bool, error) { + sharedCount := int64(0) + if result := s.db.WithContext(ctx).Select("*").Table("saved_queries_permissions").Where("query_id = ? AND shared_to_user_id IS NOT NULL", queryID).Count(&sharedCount).Limit(1); result.Error != nil { + return false, CheckError(result) + } else if sharedCount > 0 { + return true, nil + } else { + return false, nil + } +} diff --git a/cmd/api/src/database/saved_queries_permissions_test.go b/cmd/api/src/database/saved_queries_permissions_test.go index 92a936cee4..ff39d21da0 100644 --- a/cmd/api/src/database/saved_queries_permissions_test.go +++ b/cmd/api/src/database/saved_queries_permissions_test.go @@ -23,6 +23,9 @@ import ( "context" "testing" + uuid2 "github.com/gofrs/uuid" + "github.com/google/uuid" + "github.com/specterops/bloodhound/src/database" "github.com/specterops/bloodhound/src/model" "github.com/specterops/bloodhound/src/test/integration" @@ -30,7 +33,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestSavedQueriesPermissions_SharingToUser(t *testing.T) { +func TestSavedQueriesPermissions_CheckUserHasPermissionToSavedQuery(t *testing.T) { var ( testCtx = context.Background() dbInst = integration.SetupDB(t) @@ -46,34 +49,277 @@ func TestSavedQueriesPermissions_SharingToUser(t *testing.T) { }) require.NoError(t, err) - query, err := dbInst.CreateSavedQuery(testCtx, user.ID, "Test Query", "MATCH(n) RETURN n", "An example Query") + query, err := dbInst.CreateSavedQuery(testCtx, user.ID, "Test Query", "TESTING", "Example") + require.NoError(t, err) + + _, err = dbInst.CreateSavedQueryPermissionToUser(testCtx, query.ID, user2.ID) require.NoError(t, err) - permissions, err := dbInst.CreateSavedQueryPermissionToUser(testCtx, query.ID, user2.ID) + result, err := dbInst.CheckUserHasPermissionToSavedQuery(testCtx, query.ID, user2.ID) require.NoError(t, err) + assert.True(t, result) - assert.Equal(t, database.NullUUID(user2.ID), permissions.SharedToUserID) - assert.Equal(t, false, permissions.Public) - assert.Equal(t, query.ID, permissions.QueryID) + result, err = dbInst.CheckUserHasPermissionToSavedQuery(testCtx, query.ID, user.ID) + require.NoError(t, err) + assert.False(t, result) } -func TestSavedQueriesPermissions_SharingToGlobal(t *testing.T) { +func TestSavedQueriesPermissions_CreateSavedQueryPermissionsBatch(t *testing.T) { var ( testCtx = context.Background() dbInst = integration.SetupDB(t) ) + user1, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: userPrincipal, + }) + require.NoError(t, err) - user, err := dbInst.CreateUser(testCtx, model.User{ + users := make([]model.User, 0) + for i := 0; i < 5; i++ { + user, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: uuid.NewString(), + }) + require.NoError(t, err) + users = append(users, user) + } + + query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example") + require.NoError(t, err) + + permissions := make([]model.SavedQueriesPermissions, 0) + for _, user := range users { + permissions = append(permissions, model.SavedQueriesPermissions{ + QueryID: query.ID, + Public: false, + SharedToUserID: database.NullUUID(user.ID), + }) + } + + _, err = dbInst.CreateSavedQueryPermissionsBatch(testCtx, permissions) + require.NoError(t, err) + + permissions, err = dbInst.GetPermissionsForSavedQuery(testCtx, query.ID) + require.NoError(t, err) + assert.Len(t, permissions, 5) +} + +func TestSavedQueriesPermissions_CreateSavedQueryPermissionsBatchBadDataError(t *testing.T) { + var ( + testCtx = context.Background() + dbInst = integration.SetupDB(t) + ) + user1, err := dbInst.CreateUser(testCtx, model.User{ PrincipalName: userPrincipal, }) require.NoError(t, err) - query, err := dbInst.CreateSavedQuery(testCtx, user.ID, "Test Query", "MATCH(n) RETURN n", "An example Query") + users := make([]model.User, 0) + for i := 0; i < 5; i++ { + user, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: uuid.NewString(), + }) + require.NoError(t, err) + users = append(users, user) + } + + query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example") require.NoError(t, err) - permissions, err := dbInst.CreateSavedQueryPermissionToPublic(testCtx, query.ID) + permissions := make([]model.SavedQueriesPermissions, 0) + for _, user := range users { + permissions = append(permissions, model.SavedQueriesPermissions{ + QueryID: query.ID, + Public: false, + SharedToUserID: database.NullUUID(user.ID), + }) + } + + invalidUUID, _ := uuid2.NewV4() + permissions[3].SharedToUserID = database.NullUUID(invalidUUID) + + _, err = dbInst.CreateSavedQueryPermissionsBatch(testCtx, permissions) + require.Error(t, err) + + permissions, err = dbInst.GetPermissionsForSavedQuery(testCtx, query.ID) require.NoError(t, err) - assert.Equal(t, true, permissions.Public) - assert.Equal(t, query.ID, permissions.QueryID) + assert.Len(t, permissions, 0) +} + +func TestSavedQueriesPermissions_GetScopeForSavedQueryPublic(t *testing.T) { + var ( + testCtx = context.Background() + dbInst = integration.SetupDB(t) + ) + + user1, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: userPrincipal, + }) + require.NoError(t, err) + + user2, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: user2Principal, + }) + require.NoError(t, err) + + query, err := dbInst.CreateSavedQuery(testCtx, user2.ID, "Test Query", "TESTING", "Example") + require.NoError(t, err) + + _, err = dbInst.CreateSavedQueryPermissionToPublic(testCtx, query.ID) + require.NoError(t, err) + + scope, err := dbInst.GetScopeForSavedQuery(testCtx, query.ID, user1.ID) + require.NoError(t, err) + + require.Equal(t, database.SavedQueryScopeMap{ + model.SavedQueryScopePublic: true, + model.SavedQueryScopeOwned: false, + model.SavedQueryScopeShared: false, + }, scope) +} + +func TestSavedQueriesPermissions_GetScopeForSavedQueryShared(t *testing.T) { + var ( + testCtx = context.Background() + dbInst = integration.SetupDB(t) + ) + + user1, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: userPrincipal, + }) + require.NoError(t, err) + + user2, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: user2Principal, + }) + require.NoError(t, err) + + query, err := dbInst.CreateSavedQuery(testCtx, user2.ID, "Test Query", "TESTING", "Example") + require.NoError(t, err) + + _, err = dbInst.CreateSavedQueryPermissionToUser(testCtx, query.ID, user1.ID) + require.NoError(t, err) + + scope, err := dbInst.GetScopeForSavedQuery(testCtx, query.ID, user1.ID) + require.NoError(t, err) + + require.Equal(t, database.SavedQueryScopeMap{ + model.SavedQueryScopePublic: false, + model.SavedQueryScopeOwned: false, + model.SavedQueryScopeShared: true, + }, scope) +} + +func TestSavedQueriesPermissions_GetScopeForSavedQueryOwned(t *testing.T) { + var ( + testCtx = context.Background() + dbInst = integration.SetupDB(t) + ) + + user1, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: userPrincipal, + }) + require.NoError(t, err) + + user2, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: user2Principal, + }) + require.NoError(t, err) + + query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example") + require.NoError(t, err) + + _, err = dbInst.CreateSavedQueryPermissionToUser(testCtx, query.ID, user2.ID) + require.NoError(t, err) + + scope, err := dbInst.GetScopeForSavedQuery(testCtx, query.ID, user1.ID) + require.NoError(t, err) + + require.Equal(t, database.SavedQueryScopeMap{ + model.SavedQueryScopePublic: false, + model.SavedQueryScopeOwned: true, + model.SavedQueryScopeShared: false, + }, scope) +} + +func TestSavedQueriesPermissions_DeleteSavedQueryPermissionsForUser(t *testing.T) { + var ( + testCtx = context.Background() + dbInst = integration.SetupDB(t) + ) + + user1, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: userPrincipal, + }) + require.NoError(t, err) + + user2, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: user2Principal, + }) + require.NoError(t, err) + + query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example") + require.NoError(t, err) + + _, err = dbInst.CreateSavedQueryPermissionToUser(testCtx, query.ID, user2.ID) + require.NoError(t, err) + + hasPermission, err := dbInst.CheckUserHasPermissionToSavedQuery(testCtx, query.ID, user2.ID) + require.NoError(t, err) + require.True(t, hasPermission) + + err = dbInst.DeleteSavedQueryPermissionsForUser(testCtx, query.ID, user2.ID) + require.NoError(t, err) + + hasPermission, err = dbInst.CheckUserHasPermissionToSavedQuery(testCtx, query.ID, user2.ID) + require.NoError(t, err) + assert.False(t, hasPermission) +} + +func TestSavedQueriesPermissions_DeleteSavedQueryPermissionPublic(t *testing.T) { + var ( + testCtx = context.Background() + dbInst = integration.SetupDB(t) + ) + + user1, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: userPrincipal, + }) + require.NoError(t, err) + + query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example") + require.NoError(t, err) + + _, err = dbInst.CreateSavedQueryPermissionToPublic(testCtx, query.ID) + require.NoError(t, err) + + err = dbInst.DeleteSavedQueryPermissionPublic(testCtx, query.ID) + require.NoError(t, err) +} + +func TestSavedQueriesPermissions_IsSavedQueryShared(t *testing.T) { + var ( + testCtx = context.Background() + dbInst = integration.SetupDB(t) + ) + + user1, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: userPrincipal, + }) + require.NoError(t, err) + + user2, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: user2Principal, + }) + require.NoError(t, err) + + query, err := dbInst.CreateSavedQuery(testCtx, user1.ID, "Test Query", "TESTING", "Example") + require.NoError(t, err) + + _, err = dbInst.CreateSavedQueryPermissionToUser(testCtx, query.ID, user2.ID) + require.NoError(t, err) + + hasPermission, err := dbInst.CheckUserHasPermissionToSavedQuery(testCtx, query.ID, user2.ID) + require.NoError(t, err) + require.True(t, hasPermission) } diff --git a/packages/go/openapi/doc/openapi.json b/packages/go/openapi/doc/openapi.json index e30335bdf5..43687e3561 100644 --- a/packages/go/openapi/doc/openapi.json +++ b/packages/go/openapi/doc/openapi.json @@ -4437,6 +4437,93 @@ } } }, + "/api/v2/saved-queries/{saved_query_id}/share": { + "parameters": [ + { + "$ref": "#/components/parameters/header.prefer" + }, + { + "name": "saved_query_id", + "description": "ID of the saved query", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "put": { + "operationId": "ShareSavedQuery", + "summary": "Share a saved query or set it to public", + "description": "Shares an existing saved query or makes it public", + "tags": [ + "Cypher", + "Community", + "Enterprise" + ], + "requestBody": { + "description": "The request body for sharing a saved query", + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "user_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "public": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/model.saved-queries-permissions" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/bad-request" + }, + "401": { + "$ref": "#/components/responses/unauthorized" + }, + "403": { + "$ref": "#/components/responses/forbidden" + }, + "404": { + "$ref": "#/components/responses/not-found" + }, + "429": { + "$ref": "#/components/responses/too-many-requests" + }, + "500": { + "$ref": "#/components/responses/internal-server-error" + } + } + } + }, "/api/v2/graphs/cypher": { "parameters": [ { @@ -14670,6 +14757,42 @@ } ] }, + "model.saved-queries-permissions": { + "allOf": [ + { + "$ref": "#/components/schemas/model.components.int64.id" + }, + { + "$ref": "#/components/schemas/model.components.timestamps" + }, + { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "shared_to_user_id": { + "allOf": [ + { + "$ref": "#/components/schemas/null.uuid" + }, + { + "readOnly": true + } + ] + }, + "query_id": { + "type": "integer", + "format": "int64" + }, + "public": { + "type": "boolean" + } + } + } + ] + }, "api.response.time-window": { "type": "object", "properties": { diff --git a/packages/go/openapi/src/openapi.yaml b/packages/go/openapi/src/openapi.yaml index 6308f29a8f..c23bb978f9 100644 --- a/packages/go/openapi/src/openapi.yaml +++ b/packages/go/openapi/src/openapi.yaml @@ -324,6 +324,8 @@ paths: $ref: './paths/cypher.saved-queries.yaml' /api/v2/saved-queries/{saved_query_id}: $ref: './paths/cypher.saved-queries.id.yaml' + /api/v2/saved-queries/{saved_query_id}/share: + $ref: './paths/cypher.saved-queries.id.share.yaml' /api/v2/graphs/cypher: $ref: './paths/cypher.graphs.cypher.yaml' diff --git a/packages/go/openapi/src/paths/cypher.saved-queries.id.share.yaml b/packages/go/openapi/src/paths/cypher.saved-queries.id.share.yaml new file mode 100644 index 0000000000..dcab69fcc5 --- /dev/null +++ b/packages/go/openapi/src/paths/cypher.saved-queries.id.share.yaml @@ -0,0 +1,72 @@ +# Copyright 2024 Specter Ops, Inc. +# +# Licensed under the Apache License, Version 2.0 +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +parameters: + - $ref: './../parameters/header.prefer.yaml' + - name: saved_query_id + description: ID of the saved query + in: path + required: true + schema: + type: integer + format: int64 +put: + operationId: ShareSavedQuery + summary: Share a saved query or set it to public + description: Shares an existing saved query or makes it public + tags: + - Cypher + - Community + - Enterprise + requestBody: + description: The request body for sharing a saved query + required: true + content: + application/json: + schema: + properties: + user_ids: + type: array + items: + type: string + format: uuid + public: + type: boolean + + responses: + 201: + description: Created + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: './../schemas/model.saved-queries-permissions.yaml' + 400: + $ref: './../responses/bad-request.yaml' + 401: + $ref: './../responses/unauthorized.yaml' + 403: + $ref: './../responses/forbidden.yaml' + 404: + $ref: './../responses/not-found.yaml' + 429: + $ref: './../responses/too-many-requests.yaml' + 500: + $ref: './../responses/internal-server-error.yaml' \ No newline at end of file diff --git a/packages/go/openapi/src/schemas/model.saved-queries-permissions.yaml b/packages/go/openapi/src/schemas/model.saved-queries-permissions.yaml new file mode 100644 index 0000000000..35812c0d46 --- /dev/null +++ b/packages/go/openapi/src/schemas/model.saved-queries-permissions.yaml @@ -0,0 +1,33 @@ +# Copyright 2024 Specter Ops, Inc. +# +# Licensed under the Apache License, Version 2.0 +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +allOf: + - $ref: './model.components.int64.id.yaml' + - $ref: './model.components.timestamps.yaml' + - type: object + properties: + id: + type: integer + format: int64 + shared_to_user_id: + allOf: + - $ref: './null.uuid.yaml' + - readOnly: true + query_id: + type: integer + format: int64 + public: + type: boolean \ No newline at end of file