Skip to content

Commit

Permalink
BED-4707 added saved queries unshare endpoint (#793)
Browse files Browse the repository at this point in the history
* BED-4707 added saved queries unshare endpoint
  • Loading branch information
mvlipka authored Aug 23, 2024
1 parent a14c9fb commit f076a01
Show file tree
Hide file tree
Showing 11 changed files with 697 additions and 2 deletions.
1 change: 1 addition & 0 deletions cmd/api/src/api/registration/v2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.DELETE(fmt.Sprintf("/api/v2/saved-queries/{%s}/permissions", api.URIPathVariableSavedQueryID), resources.DeleteSavedQueryPermissions).RequirePermissions(permissions.SavedQueriesWrite),

// Azure Entity API
routerInst.GET("/api/v2/azure/{entity_type}", resources.GetAZEntity).RequirePermissions(permissions.GraphDBRead),
Expand Down
90 changes: 90 additions & 0 deletions cmd/api/src/api/v2/saved_queries_permissions.go
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)
}
}
295 changes: 295 additions & 0 deletions cmd/api/src/api/v2/saved_queries_permissions_test.go
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)
})
}
Loading

0 comments on commit f076a01

Please sign in to comment.