From f076a012bc97e82791f1969e1ae3c789a8643cac Mon Sep 17 00:00:00 2001 From: Michael Lipka Date: Fri, 23 Aug 2024 09:14:14 -0500 Subject: [PATCH] BED-4707 added saved queries unshare endpoint (#793) * BED-4707 added saved queries unshare endpoint --- cmd/api/src/api/registration/v2.go | 1 + .../src/api/v2/saved_queries_permissions.go | 90 ++++++ .../api/v2/saved_queries_permissions_test.go | 295 ++++++++++++++++++ cmd/api/src/database/mocks/db.go | 43 +++ cmd/api/src/database/saved_queries.go | 9 + .../src/database/saved_queries_permissions.go | 18 +- .../saved_queries_permissions_test.go | 85 +++++ cmd/api/src/database/saved_queries_test.go | 23 ++ packages/go/openapi/doc/openapi.json | 71 +++++ packages/go/openapi/src/openapi.yaml | 2 + .../cypher.saved-queries.id.permissions.yaml | 62 ++++ 11 files changed, 697 insertions(+), 2 deletions(-) create mode 100644 cmd/api/src/api/v2/saved_queries_permissions.go create mode 100644 cmd/api/src/api/v2/saved_queries_permissions_test.go create mode 100644 packages/go/openapi/src/paths/cypher.saved-queries.id.permissions.yaml diff --git a/cmd/api/src/api/registration/v2.go b/cmd/api/src/api/registration/v2.go index a04efb6b11..b707701cc7 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.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), diff --git a/cmd/api/src/api/v2/saved_queries_permissions.go b/cmd/api/src/api/v2/saved_queries_permissions.go new file mode 100644 index 0000000000..24e277108e --- /dev/null +++ b/cmd/api/src/api/v2/saved_queries_permissions.go @@ -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) + } +} diff --git a/cmd/api/src/api/v2/saved_queries_permissions_test.go b/cmd/api/src/api/v2/saved_queries_permissions_test.go new file mode 100644 index 0000000000..3908979089 --- /dev/null +++ b/cmd/api/src/api/v2/saved_queries_permissions_test.go @@ -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) + }) +} diff --git a/cmd/api/src/database/mocks/db.go b/cmd/api/src/database/mocks/db.go index 259316d3ae..ddc5f4314c 100644 --- a/cmd/api/src/database/mocks/db.go +++ b/cmd/api/src/database/mocks/db.go @@ -545,6 +545,34 @@ func (mr *MockDatabaseMockRecorder) DeleteSavedQuery(arg0, arg1 interface{}) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSavedQuery", reflect.TypeOf((*MockDatabase)(nil).DeleteSavedQuery), 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) +} + +// DeleteSavedQueryPermissionsForUsers mocks base method. +func (m *MockDatabase) DeleteSavedQueryPermissionsForUsers(arg0 context.Context, arg1 int64, arg2 []uuid.UUID) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteSavedQueryPermissionsForUsers", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteSavedQueryPermissionsForUsers indicates an expected call of DeleteSavedQueryPermissionsForUsers. +func (mr *MockDatabaseMockRecorder) DeleteSavedQueryPermissionsForUsers(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSavedQueryPermissionsForUsers", reflect.TypeOf((*MockDatabase)(nil).DeleteSavedQueryPermissionsForUsers), arg0, arg1, arg2) +} + // DeleteUser mocks base method. func (m *MockDatabase) DeleteUser(arg0 context.Context, arg1 model.User) error { m.ctrl.T.Helper() @@ -1279,6 +1307,21 @@ func (mr *MockDatabaseMockRecorder) IsSavedQueryPublic(arg0, arg1 interface{}) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsSavedQueryPublic", reflect.TypeOf((*MockDatabase)(nil).IsSavedQueryPublic), arg0, arg1) } +// IsSavedQuerySharedToUser mocks base method. +func (m *MockDatabase) IsSavedQuerySharedToUser(arg0 context.Context, arg1 int64, arg2 uuid.UUID) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsSavedQuerySharedToUser", arg0, arg1, arg2) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsSavedQuerySharedToUser indicates an expected call of IsSavedQuerySharedToUser. +func (mr *MockDatabaseMockRecorder) IsSavedQuerySharedToUser(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsSavedQuerySharedToUser", reflect.TypeOf((*MockDatabase)(nil).IsSavedQuerySharedToUser), arg0, arg1, arg2) +} + // 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.go b/cmd/api/src/database/saved_queries.go index e171a7c2f1..cbabb53431 100644 --- a/cmd/api/src/database/saved_queries.go +++ b/cmd/api/src/database/saved_queries.go @@ -34,6 +34,7 @@ type SavedQueriesData interface { GetSharedSavedQueries(ctx context.Context, userID uuid.UUID) (model.SavedQueries, error) GetPublicSavedQueries(ctx context.Context) (model.SavedQueries, error) IsSavedQueryPublic(ctx context.Context, savedQueryID int64) (bool, error) + IsSavedQuerySharedToUser(ctx context.Context, queryID int64, userID uuid.UUID) (bool, error) } func (s *BloodhoundDB) GetSavedQuery(ctx context.Context, savedQueryID int64) (model.SavedQuery, error) { @@ -130,3 +131,11 @@ func (s *BloodhoundDB) IsSavedQueryPublic(ctx context.Context, savedQueryID int6 return false, nil } } + +// IsSavedQuerySharedToUser returns true or false whether a provided saved query is shared with a provided user +func (s *BloodhoundDB) IsSavedQuerySharedToUser(ctx context.Context, queryID int64, userID uuid.UUID) (bool, error) { + rows := int64(0) + result := s.db.WithContext(ctx).Table("saved_queries_permissions").Where("query_id = ? AND shared_to_user_id = ?", queryID, userID).Count(&rows) + + return rows > 0, CheckError(result) +} diff --git a/cmd/api/src/database/saved_queries_permissions.go b/cmd/api/src/database/saved_queries_permissions.go index e61bf6c660..86c720d36a 100644 --- a/cmd/api/src/database/saved_queries_permissions.go +++ b/cmd/api/src/database/saved_queries_permissions.go @@ -29,6 +29,8 @@ type SavedQueriesPermissionsData interface { CreateSavedQueryPermissionToPublic(ctx context.Context, queryID int64) (model.SavedQueriesPermissions, error) CheckUserHasPermissionToSavedQuery(ctx context.Context, queryID int64, userID uuid.UUID) (bool, error) GetPermissionsForSavedQuery(ctx context.Context, queryID int64) (model.SavedQueriesPermissions, error) + DeleteSavedQueryPermissionsForUser(ctx context.Context, queryID int64, userID uuid.UUID) error + DeleteSavedQueryPermissionsForUsers(ctx context.Context, queryID int64, userIDs []uuid.UUID) error } // CreateSavedQueryPermissionToUser creates a new entry to the SavedQueriesPermissions table granting a provided user id to access a provided query @@ -53,10 +55,12 @@ func (s *BloodhoundDB) CreateSavedQueryPermissionToPublic(ctx context.Context, q } // CheckUserHasPermissionToSavedQuery returns true or false depending on if the given userID has permission to read the given queryID +// This does not check for ownership 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 @@ -65,3 +69,13 @@ func (s *BloodhoundDB) GetPermissionsForSavedQuery(ctx context.Context, queryID result := s.db.WithContext(ctx).Where("query_id = ?", queryID).Find(&queryPermissions) return queryPermissions, CheckError(result) } + +// 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{})) +} + +// DeleteSavedQueryPermissionsForUsers batch deletes permissions associated a query id and a list of users +func (s *BloodhoundDB) DeleteSavedQueryPermissionsForUsers(ctx context.Context, queryID int64, userIDs []uuid.UUID) error { + return CheckError(s.db.WithContext(ctx).Table("saved_queries_permissions").Where("query_id = ? AND shared_to_user_id IN ?", queryID, userIDs).Delete(&model.SavedQueriesPermissions{})) +} diff --git a/cmd/api/src/database/saved_queries_permissions_test.go b/cmd/api/src/database/saved_queries_permissions_test.go index 92a936cee4..f437173b15 100644 --- a/cmd/api/src/database/saved_queries_permissions_test.go +++ b/cmd/api/src/database/saved_queries_permissions_test.go @@ -21,6 +21,7 @@ package database_test import ( "context" + "github.com/gofrs/uuid" "testing" "github.com/specterops/bloodhound/src/database" @@ -77,3 +78,87 @@ func TestSavedQueriesPermissions_SharingToGlobal(t *testing.T) { assert.Equal(t, true, permissions.Public) assert.Equal(t, query.ID, permissions.QueryID) } + +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_DeleteSavedQueryPermissionForUsers(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) + + user3, err := dbInst.CreateUser(testCtx, model.User{ + PrincipalName: "ausername", + }) + 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) + + _, err = dbInst.CreateSavedQueryPermissionToUser(testCtx, query.ID, user3.ID) + require.NoError(t, err) + + hasPermission, err := dbInst.CheckUserHasPermissionToSavedQuery(testCtx, query.ID, user2.ID) + require.NoError(t, err) + require.True(t, hasPermission) + + hasPermission, err = dbInst.CheckUserHasPermissionToSavedQuery(testCtx, query.ID, user3.ID) + require.NoError(t, err) + require.True(t, hasPermission) + + err = dbInst.DeleteSavedQueryPermissionsForUsers(testCtx, query.ID, []uuid.UUID{user2.ID, user3.ID}) + require.NoError(t, err) + + hasPermission, err = dbInst.CheckUserHasPermissionToSavedQuery(testCtx, query.ID, user2.ID) + require.NoError(t, err) + assert.False(t, hasPermission) + + hasPermission, err = dbInst.CheckUserHasPermissionToSavedQuery(testCtx, query.ID, user3.ID) + require.NoError(t, err) + assert.False(t, hasPermission) +} diff --git a/cmd/api/src/database/saved_queries_test.go b/cmd/api/src/database/saved_queries_test.go index b053ce06cf..8cc7cd171b 100644 --- a/cmd/api/src/database/saved_queries_test.go +++ b/cmd/api/src/database/saved_queries_test.go @@ -22,6 +22,7 @@ package database_test import ( "context" "fmt" + "github.com/stretchr/testify/assert" "testing" "github.com/gofrs/uuid" @@ -66,3 +67,25 @@ func TestSavedQueries_ListSavedQueries(t *testing.T) { t.Fatalf("Expected 3 saved queries to be returned") } } + +func TestSavedQueries_IsSavedQuerySharedToUser(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.CreateSavedQueryPermissionToUser(testCtx, query.ID, user1.ID) + require.NoError(t, err) + + isShared, err := dbInst.IsSavedQuerySharedToUser(testCtx, query.ID, user1.ID) + require.NoError(t, err) + assert.True(t, isShared) +} diff --git a/packages/go/openapi/doc/openapi.json b/packages/go/openapi/doc/openapi.json index e30335bdf5..96cce67ea0 100644 --- a/packages/go/openapi/doc/openapi.json +++ b/packages/go/openapi/doc/openapi.json @@ -4437,6 +4437,77 @@ } } }, + "/api/v2/saved-queries/{saved_query_id}/permissions": { + "parameters": [ + { + "$ref": "#/components/parameters/header.prefer" + }, + { + "name": "saved_query_id", + "description": "ID of the saved query", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "delete": { + "operationId": "DeleteSavedQueryPermissions", + "summary": "Revokes permission of a saved query from users", + "description": "Revokes permission of a saved query from a given set of users", + "tags": [ + "Cypher", + "Community", + "Enterprise" + ], + "requestBody": { + "description": "The request body for revoking permissions of a saved query from users", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "user_ids": { + "type": "array", + "description": "A list of user ids that will have their permission revoked from the given saved query", + "items": { + "type": "string", + "format": "uuid" + } + } + } + } + } + } + }, + "responses": { + "204": { + "$ref": "#/components/responses/no-content" + }, + "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": [ { diff --git a/packages/go/openapi/src/openapi.yaml b/packages/go/openapi/src/openapi.yaml index 6308f29a8f..3b2167ff76 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}/permissions: + $ref: './paths/cypher.saved-queries.id.permissions.yaml' /api/v2/graphs/cypher: $ref: './paths/cypher.graphs.cypher.yaml' diff --git a/packages/go/openapi/src/paths/cypher.saved-queries.id.permissions.yaml b/packages/go/openapi/src/paths/cypher.saved-queries.id.permissions.yaml new file mode 100644 index 0000000000..94baa75897 --- /dev/null +++ b/packages/go/openapi/src/paths/cypher.saved-queries.id.permissions.yaml @@ -0,0 +1,62 @@ +# 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: int32 +delete: + operationId: DeleteSavedQueryPermissions + summary: Revokes permission of a saved query from users + description: Revokes permission of a saved query from a given set of users + tags: + - Cypher + - Community + - Enterprise + requestBody: + description: The request body for revoking permissions of a saved query from users + required: true + content: + application/json: + schema: + type: object + properties: + user_ids: + type: array + description: A list of user ids that will have their permission revoked from the given saved query + items: + type: string + format: uuid + responses: + 204: + $ref: './../responses/no-content.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'