-
Notifications
You must be signed in to change notification settings - Fork 109
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
BED-4707 added saved queries unshare endpoint (#793)
* BED-4707 added saved queries unshare endpoint
- Loading branch information
Showing
11 changed files
with
697 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
/* | ||
* 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 | ||
*/ | ||
|
||
package v2 | ||
|
||
import ( | ||
"encoding/json" | ||
"github.com/gofrs/uuid" | ||
"github.com/gorilla/mux" | ||
"github.com/specterops/bloodhound/src/api" | ||
"github.com/specterops/bloodhound/src/auth" | ||
ctx2 "github.com/specterops/bloodhound/src/ctx" | ||
"github.com/specterops/bloodhound/src/model" | ||
"net/http" | ||
"slices" | ||
"strconv" | ||
) | ||
|
||
// DeleteSavedQueryPermissionsRequest represents the payload sent to the unshare endpoint | ||
type DeleteSavedQueryPermissionsRequest struct { | ||
UserIds []uuid.UUID `json:"user_ids"` | ||
} | ||
|
||
// DeleteSavedQueryPermissions allows an owner of a shared query, a user that has a saved query shared to them, or an admin, to remove sharing privileges. | ||
// A user who owns a query may unshare a query from anyone they have shared to | ||
// A user who had a query shared to them may unshare that query from themselves | ||
// And admins may unshare queries that have been shared to other users | ||
func (s Resources) DeleteSavedQueryPermissions(response http.ResponseWriter, request *http.Request) { | ||
var ( | ||
rawSavedQueryID = mux.Vars(request)[api.URIPathVariableSavedQueryID] | ||
deleteRequest DeleteSavedQueryPermissionsRequest | ||
) | ||
|
||
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.ErrorResponseDetailsFromMalformed, request), response) | ||
} else if err = json.NewDecoder(request.Body).Decode(&deleteRequest); err != nil { | ||
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, err.Error(), request), response) | ||
} else { | ||
// Check if the user is attempting to unshare a query from themselves | ||
if slices.Contains(deleteRequest.UserIds, user.ID) { | ||
if isShared, err := s.DB.IsSavedQuerySharedToUser(request.Context(), savedQueryID, user.ID); err != nil { | ||
api.HandleDatabaseError(request, response, err) | ||
return | ||
} else if !isShared { | ||
|
||
// The user cannot unshare a saved query if a saved query permission does not exist for them. This means a user cannot unshare a query that they don't own, or hasn't been shared with them | ||
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusBadRequest, "User cannot unshare a query from themselves that is not shared to them", request), response) | ||
return | ||
} | ||
} else { | ||
// User is attempting to unshare a query from another user | ||
isAdmin := user.Roles.Has(model.Role{Name: auth.RoleAdministrator}) | ||
if !isAdmin { | ||
// If a user is not admin, then they need to own the query in order to unshare it | ||
if savedQueryBelongsToUser, err := s.DB.SavedQueryBelongsToUser(request.Context(), user.ID, savedQueryID); err != nil { | ||
api.HandleDatabaseError(request, response, err) | ||
return | ||
} else if !savedQueryBelongsToUser { | ||
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusUnauthorized, "Query does not belong to the user", request), response) | ||
return | ||
} | ||
} | ||
|
||
} | ||
|
||
// Unshare the queries | ||
if err = s.DB.DeleteSavedQueryPermissionsForUsers(request.Context(), savedQueryID, deleteRequest.UserIds); err != nil { | ||
api.HandleDatabaseError(request, response, err) | ||
return | ||
} | ||
response.WriteHeader(http.StatusNoContent) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,295 @@ | ||
/* | ||
* 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 | ||
*/ | ||
|
||
package v2_test | ||
|
||
import ( | ||
"fmt" | ||
uuid2 "github.com/gofrs/uuid" | ||
"github.com/google/uuid" | ||
"github.com/gorilla/mux" | ||
"github.com/specterops/bloodhound/headers" | ||
"github.com/specterops/bloodhound/mediatypes" | ||
"github.com/specterops/bloodhound/src/api" | ||
v2 "github.com/specterops/bloodhound/src/api/v2" | ||
"github.com/specterops/bloodhound/src/database/mocks" | ||
"github.com/specterops/bloodhound/src/test/must" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
"go.uber.org/mock/gomock" | ||
"net/http" | ||
"net/http/httptest" | ||
"testing" | ||
) | ||
|
||
func TestResources_DeleteSavedQueryPermissions(t *testing.T) { | ||
|
||
userId, err := uuid2.NewV4() | ||
require.Nil(t, err) | ||
userId2, err := uuid2.NewV4() | ||
require.Nil(t, err) | ||
userId3, err := uuid2.NewV4() | ||
require.Nil(t, err) | ||
|
||
endpoint := "/api/v2/saved-queries/{%s}/permissions" | ||
savedQueryId := "1" | ||
|
||
t.Run("user can unshare their owned saved query", func(t *testing.T) { | ||
var ( | ||
mockCtrl = gomock.NewController(t) | ||
mockDB = mocks.NewMockDatabase(mockCtrl) | ||
resources = v2.Resources{DB: mockDB} | ||
) | ||
defer mockCtrl.Finish() | ||
|
||
mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), userId, int64(1)).Return(true, nil) | ||
|
||
payload := v2.DeleteSavedQueryPermissionsRequest{ | ||
UserIds: []uuid2.UUID{userId2, userId3}, | ||
} | ||
|
||
mockDB.EXPECT().DeleteSavedQueryPermissionsForUsers(gomock.Any(), int64(1), gomock.Any()).Return(nil) | ||
|
||
req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), http.MethodDelete, fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) | ||
require.Nil(t, err) | ||
|
||
req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) | ||
req = mux.SetURLVars(req, map[string]string{api.URIPathVariableSavedQueryID: savedQueryId}) | ||
|
||
response := httptest.NewRecorder() | ||
handler := http.HandlerFunc(resources.DeleteSavedQueryPermissions) | ||
|
||
handler.ServeHTTP(response, req) | ||
assert.Equal(t, http.StatusNoContent, response.Code) | ||
}) | ||
|
||
t.Run("user can unshare queries they do not own as an admin", func(t *testing.T) { | ||
var ( | ||
mockCtrl = gomock.NewController(t) | ||
mockDB = mocks.NewMockDatabase(mockCtrl) | ||
resources = v2.Resources{DB: mockDB} | ||
) | ||
defer mockCtrl.Finish() | ||
|
||
payload := v2.DeleteSavedQueryPermissionsRequest{ | ||
UserIds: []uuid2.UUID{userId2, userId3}, | ||
} | ||
|
||
mockDB.EXPECT().DeleteSavedQueryPermissionsForUsers(gomock.Any(), int64(1), gomock.Any()).Return(nil) | ||
|
||
req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(userId), http.MethodDelete, fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) | ||
require.Nil(t, err) | ||
|
||
req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) | ||
req = mux.SetURLVars(req, map[string]string{api.URIPathVariableSavedQueryID: savedQueryId}) | ||
|
||
response := httptest.NewRecorder() | ||
handler := http.HandlerFunc(resources.DeleteSavedQueryPermissions) | ||
|
||
handler.ServeHTTP(response, req) | ||
assert.Equal(t, http.StatusNoContent, response.Code) | ||
}) | ||
|
||
t.Run("user errors unsharing query from themselves", func(t *testing.T) { | ||
var ( | ||
mockCtrl = gomock.NewController(t) | ||
mockDB = mocks.NewMockDatabase(mockCtrl) | ||
resources = v2.Resources{DB: mockDB} | ||
) | ||
defer mockCtrl.Finish() | ||
|
||
payload := v2.DeleteSavedQueryPermissionsRequest{ | ||
UserIds: []uuid2.UUID{userId}, | ||
} | ||
|
||
mockDB.EXPECT().IsSavedQuerySharedToUser(gomock.Any(), int64(1), userId).Return(true, nil) | ||
mockDB.EXPECT().DeleteSavedQueryPermissionsForUsers(gomock.Any(), int64(1), []uuid2.UUID{userId}).Return(fmt.Errorf("an error")) | ||
|
||
req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(userId), http.MethodDelete, fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) | ||
require.Nil(t, err) | ||
|
||
req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) | ||
req = mux.SetURLVars(req, map[string]string{api.URIPathVariableSavedQueryID: savedQueryId}) | ||
|
||
response := httptest.NewRecorder() | ||
handler := http.HandlerFunc(resources.DeleteSavedQueryPermissions) | ||
|
||
handler.ServeHTTP(response, req) | ||
assert.Equal(t, http.StatusInternalServerError, response.Code) | ||
}) | ||
|
||
t.Run("user can unshare queries shared to them", func(t *testing.T) { | ||
var ( | ||
mockCtrl = gomock.NewController(t) | ||
mockDB = mocks.NewMockDatabase(mockCtrl) | ||
resources = v2.Resources{DB: mockDB} | ||
) | ||
defer mockCtrl.Finish() | ||
|
||
payload := v2.DeleteSavedQueryPermissionsRequest{ | ||
UserIds: []uuid2.UUID{userId}, | ||
} | ||
|
||
mockDB.EXPECT().IsSavedQuerySharedToUser(gomock.Any(), int64(1), userId).Return(true, nil) | ||
mockDB.EXPECT().DeleteSavedQueryPermissionsForUsers(gomock.Any(), int64(1), []uuid2.UUID{userId}).Return(nil) | ||
|
||
req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(userId), http.MethodDelete, fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) | ||
require.Nil(t, err) | ||
|
||
req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) | ||
req = mux.SetURLVars(req, map[string]string{api.URIPathVariableSavedQueryID: savedQueryId}) | ||
|
||
response := httptest.NewRecorder() | ||
handler := http.HandlerFunc(resources.DeleteSavedQueryPermissions) | ||
|
||
handler.ServeHTTP(response, req) | ||
assert.Equal(t, http.StatusNoContent, response.Code) | ||
}) | ||
|
||
t.Run("user cannot unshare a query that wasn't shared to them", func(t *testing.T) { | ||
var ( | ||
mockCtrl = gomock.NewController(t) | ||
mockDB = mocks.NewMockDatabase(mockCtrl) | ||
resources = v2.Resources{DB: mockDB} | ||
) | ||
defer mockCtrl.Finish() | ||
|
||
payload := v2.DeleteSavedQueryPermissionsRequest{ | ||
UserIds: []uuid2.UUID{userId}, | ||
} | ||
|
||
mockDB.EXPECT().IsSavedQuerySharedToUser(gomock.Any(), int64(1), userId).Return(false, nil) | ||
|
||
req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(userId), http.MethodDelete, fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) | ||
require.Nil(t, err) | ||
|
||
req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) | ||
req = mux.SetURLVars(req, map[string]string{api.URIPathVariableSavedQueryID: savedQueryId}) | ||
|
||
response := httptest.NewRecorder() | ||
handler := http.HandlerFunc(resources.DeleteSavedQueryPermissions) | ||
|
||
handler.ServeHTTP(response, req) | ||
assert.Equal(t, http.StatusBadRequest, response.Code) | ||
}) | ||
|
||
t.Run("error checking if query is shared with user", func(t *testing.T) { | ||
var ( | ||
mockCtrl = gomock.NewController(t) | ||
mockDB = mocks.NewMockDatabase(mockCtrl) | ||
resources = v2.Resources{DB: mockDB} | ||
) | ||
defer mockCtrl.Finish() | ||
|
||
payload := v2.DeleteSavedQueryPermissionsRequest{ | ||
UserIds: []uuid2.UUID{userId}, | ||
} | ||
|
||
mockDB.EXPECT().IsSavedQuerySharedToUser(gomock.Any(), int64(1), userId).Return(false, fmt.Errorf("an error")) | ||
|
||
req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(userId), http.MethodDelete, fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) | ||
require.Nil(t, err) | ||
|
||
req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) | ||
req = mux.SetURLVars(req, map[string]string{api.URIPathVariableSavedQueryID: savedQueryId}) | ||
|
||
response := httptest.NewRecorder() | ||
handler := http.HandlerFunc(resources.DeleteSavedQueryPermissions) | ||
|
||
handler.ServeHTTP(response, req) | ||
assert.Equal(t, http.StatusInternalServerError, response.Code) | ||
}) | ||
|
||
t.Run("error user unsharing saved query that does not belong to them", func(t *testing.T) { | ||
var ( | ||
mockCtrl = gomock.NewController(t) | ||
mockDB = mocks.NewMockDatabase(mockCtrl) | ||
resources = v2.Resources{DB: mockDB} | ||
) | ||
defer mockCtrl.Finish() | ||
|
||
mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), userId, int64(1)).Return(false, nil) | ||
|
||
var userIds []uuid.UUID | ||
req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), http.MethodDelete, fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(userIds)) | ||
require.Nil(t, err) | ||
|
||
req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) | ||
req = mux.SetURLVars(req, map[string]string{api.URIPathVariableSavedQueryID: savedQueryId}) | ||
|
||
response := httptest.NewRecorder() | ||
handler := http.HandlerFunc(resources.DeleteSavedQueryPermissions) | ||
|
||
handler.ServeHTTP(response, req) | ||
assert.Equal(t, http.StatusUnauthorized, response.Code) | ||
}) | ||
|
||
t.Run("error database fails while unsharing to users", func(t *testing.T) { | ||
var ( | ||
mockCtrl = gomock.NewController(t) | ||
mockDB = mocks.NewMockDatabase(mockCtrl) | ||
resources = v2.Resources{DB: mockDB} | ||
) | ||
defer mockCtrl.Finish() | ||
|
||
payload := v2.DeleteSavedQueryPermissionsRequest{ | ||
UserIds: []uuid2.UUID{userId2, userId3}, | ||
} | ||
|
||
mockDB.EXPECT().DeleteSavedQueryPermissionsForUsers(gomock.Any(), int64(1), gomock.Any()).Return(fmt.Errorf("an error")) | ||
|
||
req, err := http.NewRequestWithContext(createContextWithAdminOwnerId(userId), http.MethodDelete, fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) | ||
require.Nil(t, err) | ||
|
||
req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) | ||
req = mux.SetURLVars(req, map[string]string{api.URIPathVariableSavedQueryID: savedQueryId}) | ||
|
||
response := httptest.NewRecorder() | ||
handler := http.HandlerFunc(resources.DeleteSavedQueryPermissions) | ||
|
||
handler.ServeHTTP(response, req) | ||
assert.Equal(t, http.StatusInternalServerError, response.Code) | ||
}) | ||
|
||
t.Run("error database fails while checking saved query ownership", func(t *testing.T) { | ||
var ( | ||
mockCtrl = gomock.NewController(t) | ||
mockDB = mocks.NewMockDatabase(mockCtrl) | ||
resources = v2.Resources{DB: mockDB} | ||
) | ||
defer mockCtrl.Finish() | ||
|
||
mockDB.EXPECT().SavedQueryBelongsToUser(gomock.Any(), userId, int64(1)).Return(false, fmt.Errorf("an error")) | ||
|
||
payload := v2.DeleteSavedQueryPermissionsRequest{ | ||
UserIds: []uuid2.UUID{userId2, userId3}, | ||
} | ||
|
||
req, err := http.NewRequestWithContext(createContextWithOwnerId(userId), http.MethodDelete, fmt.Sprintf(endpoint, savedQueryId), must.MarshalJSONReader(payload)) | ||
require.Nil(t, err) | ||
|
||
req.Header.Set(headers.ContentType.String(), mediatypes.ApplicationJson.String()) | ||
req = mux.SetURLVars(req, map[string]string{api.URIPathVariableSavedQueryID: savedQueryId}) | ||
|
||
response := httptest.NewRecorder() | ||
handler := http.HandlerFunc(resources.DeleteSavedQueryPermissions) | ||
|
||
handler.ServeHTTP(response, req) | ||
assert.Equal(t, http.StatusInternalServerError, response.Code) | ||
}) | ||
} |
Oops, something went wrong.