diff --git a/management/internals/modules/reverseproxy/proxy/manager.go b/management/internals/modules/reverseproxy/proxy/manager.go index 3c8ee1170..88156ef38 100644 --- a/management/internals/modules/reverseproxy/proxy/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager.go @@ -24,7 +24,7 @@ type Manager interface { GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) CountAccountProxies(ctx context.Context, accountID string) (int64, error) IsClusterAddressAvailable(ctx context.Context, clusterAddress, accountID string) (bool, error) - DeleteProxy(ctx context.Context, proxyID string) error + DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error } // OIDCValidationConfig contains the OIDC configuration needed for token validation. diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager.go b/management/internals/modules/reverseproxy/proxy/manager/manager.go index c1373888c..f168f7af4 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager.go @@ -25,7 +25,7 @@ type store interface { GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error) IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error) - DeleteProxy(ctx context.Context, proxyID string) error + DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error } // Manager handles all proxy operations @@ -178,9 +178,9 @@ func (m *Manager) IsClusterAddressAvailable(ctx context.Context, clusterAddress, return !conflicting, nil } -func (m *Manager) DeleteProxy(ctx context.Context, proxyID string) error { - if err := m.store.DeleteProxy(ctx, proxyID); err != nil { - log.WithContext(ctx).Errorf("failed to delete proxy %s: %v", proxyID, err) +func (m *Manager) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error { + if err := m.store.DeleteAccountCluster(ctx, clusterAddress, accountID); err != nil { + log.WithContext(ctx).Errorf("failed to delete cluster %s for account %s: %v", clusterAddress, accountID, err) return err } return nil diff --git a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go index 0483977aa..8f1ef7569 100644 --- a/management/internals/modules/reverseproxy/proxy/manager/manager_test.go +++ b/management/internals/modules/reverseproxy/proxy/manager/manager_test.go @@ -24,7 +24,7 @@ type mockStore struct { getProxyByAccountIDFunc func(ctx context.Context, accountID string) (*proxy.Proxy, error) countProxiesByAccountIDFunc func(ctx context.Context, accountID string) (int64, error) isClusterAddressConflictingFunc func(ctx context.Context, clusterAddress, accountID string) (bool, error) - deleteProxyFunc func(ctx context.Context, proxyID string) error + deleteAccountClusterFunc func(ctx context.Context, clusterAddress, accountID string) error } func (m *mockStore) SaveProxy(ctx context.Context, p *proxy.Proxy) error { @@ -84,9 +84,9 @@ func (m *mockStore) IsClusterAddressConflicting(ctx context.Context, clusterAddr } return false, nil } -func (m *mockStore) DeleteProxy(ctx context.Context, proxyID string) error { - if m.deleteProxyFunc != nil { - return m.deleteProxyFunc(ctx, proxyID) +func (m *mockStore) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error { + if m.deleteAccountClusterFunc != nil { + return m.deleteAccountClusterFunc(ctx, clusterAddress, accountID) } return nil } @@ -289,31 +289,33 @@ func TestGetAccountProxy(t *testing.T) { }) } -func TestDeleteProxy(t *testing.T) { +func TestDeleteAccountCluster(t *testing.T) { t.Run("success", func(t *testing.T) { - var deletedID string + var deletedCluster, deletedAccount string s := &mockStore{ - deleteProxyFunc: func(_ context.Context, proxyID string) error { - deletedID = proxyID + deleteAccountClusterFunc: func(_ context.Context, clusterAddress, accountID string) error { + deletedCluster = clusterAddress + deletedAccount = accountID return nil }, } mgr := newTestManager(s) - err := mgr.DeleteProxy(context.Background(), "proxy-1") + err := mgr.DeleteAccountCluster(context.Background(), "cluster.example.com", "acc-123") require.NoError(t, err) - assert.Equal(t, "proxy-1", deletedID) + assert.Equal(t, "cluster.example.com", deletedCluster) + assert.Equal(t, "acc-123", deletedAccount) }) t.Run("store error", func(t *testing.T) { s := &mockStore{ - deleteProxyFunc: func(_ context.Context, _ string) error { + deleteAccountClusterFunc: func(_ context.Context, _, _ string) error { return errors.New("db error") }, } mgr := newTestManager(s) - err := mgr.DeleteProxy(context.Background(), "proxy-1") + err := mgr.DeleteAccountCluster(context.Background(), "cluster.example.com", "acc-123") assert.Error(t, err) }) } diff --git a/management/internals/modules/reverseproxy/proxy/manager_mock.go b/management/internals/modules/reverseproxy/proxy/manager_mock.go index 154d0ce83..b78450796 100644 --- a/management/internals/modules/reverseproxy/proxy/manager_mock.go +++ b/management/internals/modules/reverseproxy/proxy/manager_mock.go @@ -221,18 +221,18 @@ func (mr *MockManagerMockRecorder) IsClusterAddressAvailable(ctx, clusterAddress return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsClusterAddressAvailable", reflect.TypeOf((*MockManager)(nil).IsClusterAddressAvailable), ctx, clusterAddress, accountID) } -// DeleteProxy mocks base method. -func (m *MockManager) DeleteProxy(ctx context.Context, proxyID string) error { +// DeleteAccountCluster mocks base method. +func (m *MockManager) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteProxy", ctx, proxyID) + ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, clusterAddress, accountID) ret0, _ := ret[0].(error) return ret0 } -// DeleteProxy indicates an expected call of DeleteProxy. -func (mr *MockManagerMockRecorder) DeleteProxy(ctx, proxyID interface{}) *gomock.Call { +// DeleteAccountCluster indicates an expected call of DeleteAccountCluster. +func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteProxy", reflect.TypeOf((*MockManager)(nil).DeleteProxy), ctx, proxyID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID) } // MockController is a mock of Controller interface. diff --git a/management/internals/modules/reverseproxy/proxy/proxy.go b/management/internals/modules/reverseproxy/proxy/proxy.go index 705129221..eaff09aa6 100644 --- a/management/internals/modules/reverseproxy/proxy/proxy.go +++ b/management/internals/modules/reverseproxy/proxy/proxy.go @@ -46,4 +46,5 @@ type Cluster struct { ID string Address string ConnectedProxies int + SelfHosted bool } diff --git a/management/internals/modules/reverseproxy/service/interface.go b/management/internals/modules/reverseproxy/service/interface.go index 0957a5e49..6a94aa32b 100644 --- a/management/internals/modules/reverseproxy/service/interface.go +++ b/management/internals/modules/reverseproxy/service/interface.go @@ -10,6 +10,7 @@ import ( type Manager interface { GetActiveClusters(ctx context.Context, accountID, userID string) ([]proxy.Cluster, error) + DeleteAccountCluster(ctx context.Context, accountID, userID, clusterAddress string) error GetAllServices(ctx context.Context, accountID, userID string) ([]*Service, error) GetService(ctx context.Context, accountID, userID, serviceID string) (*Service, error) CreateService(ctx context.Context, accountID, userID string, service *Service) (*Service, error) diff --git a/management/internals/modules/reverseproxy/service/interface_mock.go b/management/internals/modules/reverseproxy/service/interface_mock.go index 71a96c72c..83b2162ed 100644 --- a/management/internals/modules/reverseproxy/service/interface_mock.go +++ b/management/internals/modules/reverseproxy/service/interface_mock.go @@ -79,6 +79,20 @@ func (mr *MockManagerMockRecorder) DeleteAllServices(ctx, accountID, userID inte return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAllServices", reflect.TypeOf((*MockManager)(nil).DeleteAllServices), ctx, accountID, userID) } +// DeleteAccountCluster mocks base method. +func (m *MockManager) DeleteAccountCluster(ctx context.Context, accountID, userID, clusterAddress string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, accountID, userID, clusterAddress) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteAccountCluster indicates an expected call of DeleteAccountCluster. +func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, accountID, userID, clusterAddress interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, accountID, userID, clusterAddress) +} + // DeleteService mocks base method. func (m *MockManager) DeleteService(ctx context.Context, accountID, userID, serviceID string) error { m.ctrl.T.Helper() diff --git a/management/internals/modules/reverseproxy/service/manager/api.go b/management/internals/modules/reverseproxy/service/manager/api.go index 9f822cce1..08272077c 100644 --- a/management/internals/modules/reverseproxy/service/manager/api.go +++ b/management/internals/modules/reverseproxy/service/manager/api.go @@ -35,6 +35,7 @@ func RegisterEndpoints(manager rpservice.Manager, domainManager domainmanager.Ma accesslogsmanager.RegisterEndpoints(router, accessLogsManager) router.HandleFunc("/reverse-proxies/clusters", h.getClusters).Methods("GET", "OPTIONS") + router.HandleFunc("/reverse-proxies/clusters/{clusterAddress}", h.deleteCluster).Methods("DELETE", "OPTIONS") router.HandleFunc("/reverse-proxies/services", h.getAllServices).Methods("GET", "OPTIONS") router.HandleFunc("/reverse-proxies/services", h.createService).Methods("POST", "OPTIONS") router.HandleFunc("/reverse-proxies/services/{serviceId}", h.getService).Methods("GET", "OPTIONS") @@ -198,8 +199,30 @@ func (h *handler) getClusters(w http.ResponseWriter, r *http.Request) { Id: c.ID, Address: c.Address, ConnectedProxies: c.ConnectedProxies, + SelfHosted: c.SelfHosted, }) } util.WriteJSONObject(r.Context(), w, apiClusters) } + +func (h *handler) deleteCluster(w http.ResponseWriter, r *http.Request) { + userAuth, err := nbcontext.GetUserAuthFromContext(r.Context()) + if err != nil { + util.WriteError(r.Context(), err, w) + return + } + + clusterAddress := mux.Vars(r)["clusterAddress"] + if clusterAddress == "" { + util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "cluster address is required"), w) + return + } + + if err := h.manager.DeleteAccountCluster(r.Context(), userAuth.AccountId, userAuth.UserId, clusterAddress); err != nil { + util.WriteError(r.Context(), err, w) + return + } + + util.WriteJSONObject(r.Context(), w, util.EmptyObject{}) +} diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index 2083d1a86..285afd95f 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -123,6 +123,20 @@ func (m *Manager) GetActiveClusters(ctx context.Context, accountID, userID strin return m.store.GetActiveProxyClusters(ctx) } +// DeleteAccountCluster removes all proxy registrations for the given cluster address +// owned by the account. +func (m *Manager) DeleteAccountCluster(ctx context.Context, accountID, userID, clusterAddress string) error { + ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete) + if err != nil { + return status.NewPermissionValidationError(err) + } + if !ok { + return status.NewPermissionDeniedError() + } + + return m.store.DeleteAccountCluster(ctx, clusterAddress, accountID) +} + func (m *Manager) GetAllServices(ctx context.Context, accountID, userID string) ([]*service.Service, error) { ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read) if err != nil { diff --git a/management/internals/shared/grpc/proxy_group_access_test.go b/management/internals/shared/grpc/proxy_group_access_test.go index b4bc019f9..46dad5b56 100644 --- a/management/internals/shared/grpc/proxy_group_access_test.go +++ b/management/internals/shared/grpc/proxy_group_access_test.go @@ -53,6 +53,10 @@ func (m *mockReverseProxyManager) DeleteService(ctx context.Context, accountID, return nil } +func (m *mockReverseProxyManager) DeleteAccountCluster(_ context.Context, _, _, _ string) error { + return nil +} + func (m *mockReverseProxyManager) SetCertificateIssuedAt(ctx context.Context, accountID, reverseProxyID string) error { return nil } diff --git a/management/server/store/sql_store.go b/management/server/store/sql_store.go index 105454aeb..2d68e7d6a 100644 --- a/management/server/store/sql_store.go +++ b/management/server/store/sql_store.go @@ -5593,13 +5593,15 @@ func (s *SqlStore) IsClusterAddressConflicting(ctx context.Context, clusterAddre return count > 0, nil } -func (s *SqlStore) DeleteProxy(ctx context.Context, proxyID string) error { - result := s.db.Where(idQueryCondition, proxyID).Delete(&proxy.Proxy{}) +func (s *SqlStore) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error { + result := s.db. + Where("cluster_address = ? AND account_id = ?", clusterAddress, accountID). + Delete(&proxy.Proxy{}) if result.Error != nil { - return status.Errorf(status.Internal, "delete proxy: %v", result.Error) + return status.Errorf(status.Internal, "delete account cluster: %v", result.Error) } if result.RowsAffected == 0 { - return status.Errorf(status.NotFound, "proxy not found") + return status.Errorf(status.NotFound, "cluster not found") } return nil } @@ -5608,7 +5610,7 @@ func (s *SqlStore) GetActiveProxyClusters(ctx context.Context) ([]proxy.Cluster, var clusters []proxy.Cluster result := s.db.Model(&proxy.Proxy{}). - Select("MIN(id) as id, cluster_address as address, COUNT(*) as connected_proxies"). + Select("MIN(id) as id, cluster_address as address, COUNT(*) as connected_proxies, COUNT(account_id) > 0 as self_hosted"). Where("status = ? AND last_seen > ?", proxy.StatusConnected, time.Now().Add(-proxyActiveThreshold)). Group("cluster_address"). Scan(&clusters) diff --git a/management/server/store/store.go b/management/server/store/store.go index c7dce2c7a..a31c97bee 100644 --- a/management/server/store/store.go +++ b/management/server/store/store.go @@ -299,7 +299,7 @@ type Store interface { GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error) CountProxiesByAccountID(ctx context.Context, accountID string) (int64, error) IsClusterAddressConflicting(ctx context.Context, clusterAddress, accountID string) (bool, error) - DeleteProxy(ctx context.Context, proxyID string) error + DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error GetCustomDomainsCounts(ctx context.Context) (total int64, validated int64, err error) diff --git a/management/server/store/store_mock.go b/management/server/store/store_mock.go index 046659541..78b45f3f2 100644 --- a/management/server/store/store_mock.go +++ b/management/server/store/store_mock.go @@ -576,18 +576,18 @@ func (mr *MockStoreMockRecorder) DeletePostureChecks(ctx, accountID, postureChec return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeletePostureChecks", reflect.TypeOf((*MockStore)(nil).DeletePostureChecks), ctx, accountID, postureChecksID) } -// DeleteProxy mocks base method. -func (m *MockStore) DeleteProxy(ctx context.Context, proxyID string) error { +// DeleteAccountCluster mocks base method. +func (m *MockStore) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "DeleteProxy", ctx, proxyID) + ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, clusterAddress, accountID) ret0, _ := ret[0].(error) return ret0 } -// DeleteProxy indicates an expected call of DeleteProxy. -func (mr *MockStoreMockRecorder) DeleteProxy(ctx, proxyID interface{}) *gomock.Call { +// DeleteAccountCluster indicates an expected call of DeleteAccountCluster. +func (mr *MockStoreMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteProxy", reflect.TypeOf((*MockStore)(nil).DeleteProxy), ctx, proxyID) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockStore)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID) } // DeleteRoute mocks base method. diff --git a/proxy/management_integration_test.go b/proxy/management_integration_test.go index 2624e1fc7..9374a17c7 100644 --- a/proxy/management_integration_test.go +++ b/proxy/management_integration_test.go @@ -255,7 +255,7 @@ func (m *testProxyManager) IsClusterAddressAvailable(_ context.Context, _, _ str return true, nil } -func (m *testProxyManager) DeleteProxy(_ context.Context, _ string) error { +func (m *testProxyManager) DeleteAccountCluster(_ context.Context, _, _ string) error { return nil } @@ -312,6 +312,10 @@ func (m *storeBackedServiceManager) DeleteService(ctx context.Context, accountID return nil } +func (m *storeBackedServiceManager) DeleteAccountCluster(_ context.Context, _, _, _ string) error { + return nil +} + func (m *storeBackedServiceManager) SetCertificateIssuedAt(ctx context.Context, accountID, serviceID string) error { return nil } diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index c77b277e6..63e149e49 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -11377,21 +11377,21 @@ paths: "$ref": "#/components/responses/forbidden" '500': "$ref": "#/components/responses/internal_error" - /api/reverse-proxies/clusters/{clusterId}: + /api/reverse-proxies/clusters/{clusterAddress}: delete: summary: Delete a self-hosted proxy cluster - description: Removes a self-hosted (BYOP) proxy cluster and disconnects it. Only self-hosted clusters can be deleted. + description: Removes all self-hosted (BYOP) proxy registrations for the given cluster address owned by the account. tags: [ Services ] security: - BearerAuth: [ ] - TokenAuth: [ ] parameters: - in: path - name: clusterId + name: clusterAddress required: true schema: type: string - description: The unique identifier of the proxy cluster + description: The address of the proxy cluster responses: '200': description: Proxy cluster deleted successfully