mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-02 19:49:07 +02:00
[management] Clean up after account deletion (#7812)
Deleting an account left state behind that DeleteAccount's store associations don't reach. The Agent Network tables outlived the account, keeping its gateway domain claimed and its provider API keys stored. The proxies kept serving its gateway until they next resynced. Cloud-side state, such as managed proxy deployments, had no way to be cleaned up at all. Account deletion now runs registered hooks after the permission check and before any users or data are removed. A failing hook aborts the deletion. Agent Network registers one that tells the proxies to drop the account's gateway mappings. The account's settings, providers, policies, guardrails and budget rules are deleted in the account's transaction. Consumption counters, and the access logs of deleted accounts, are left to the background cleanup; usage records are kept.
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -162,6 +163,10 @@ func (s *SqlStore) DeleteAccount(ctx context.Context, account *types.Account) er
|
||||
return result.Error
|
||||
}
|
||||
|
||||
if err := deleteAgentNetworkAccountConfig(tx, account.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result = tx.Select(clause.Associations).Delete(account)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
@@ -179,6 +184,29 @@ func (s *SqlStore) DeleteAccount(ctx context.Context, account *types.Account) er
|
||||
return err
|
||||
}
|
||||
|
||||
// deleteAgentNetworkAccountConfig removes the account's agent network configuration. These
|
||||
// tables are not account associations, so deleting the account does not reach them. The
|
||||
// settings row holds the account's globally unique gateway domain and the provider rows
|
||||
// hold its upstream API keys. Tables that grow with traffic are left out: consumption
|
||||
// counters and access logs are swept in the background, and usage records are kept.
|
||||
func deleteAgentNetworkAccountConfig(tx *gorm.DB, accountID string) error {
|
||||
// Dependents first: policies point at providers and guardrails, and settings
|
||||
// go last, as DeleteSettings refuses while providers exist.
|
||||
models := []any{
|
||||
&agentNetworkTypes.Policy{},
|
||||
&agentNetworkTypes.Provider{},
|
||||
&agentNetworkTypes.Guardrail{},
|
||||
&agentNetworkTypes.AccountBudgetRule{},
|
||||
&agentNetworkTypes.Settings{},
|
||||
}
|
||||
for _, model := range models {
|
||||
if err := tx.Delete(model, "account_id = ?", accountID).Error; err != nil {
|
||||
return fmt.Errorf("delete %T rows: %w", model, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) UpdateAccountDomainAttributes(ctx context.Context, accountID string, domain string, category string, isPrimaryDomain bool) error {
|
||||
accountCopy := types.Account{
|
||||
Domain: domain,
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
@@ -401,6 +402,25 @@ func TestSqlite_DeleteAccount(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, o.AccountID, account.Id)
|
||||
|
||||
err = store.CreateAgentNetworkSettings(context.Background(), &agentNetworkTypes.Settings{
|
||||
AccountID: account.Id,
|
||||
Domain: "gw.example.com",
|
||||
ProxyAddress: "gw.example.com",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
agentNetworkConfig := []any{
|
||||
&agentNetworkTypes.Provider{ID: "an_provider", AccountID: account.Id, APIKey: "sk-test"},
|
||||
&agentNetworkTypes.Policy{ID: "an_policy", AccountID: account.Id},
|
||||
&agentNetworkTypes.Guardrail{ID: "an_guardrail", AccountID: account.Id},
|
||||
&agentNetworkTypes.AccountBudgetRule{ID: "an_budget_rule", AccountID: account.Id},
|
||||
}
|
||||
for _, row := range agentNetworkConfig {
|
||||
require.NoError(t, store.(*SqlStore).db.Create(row).Error, "creating %T", row)
|
||||
}
|
||||
otherProvider := &agentNetworkTypes.Provider{ID: "other_provider", AccountID: "other_account"}
|
||||
require.NoError(t, store.(*SqlStore).db.Create(otherProvider).Error)
|
||||
|
||||
err = store.DeleteAccount(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -465,6 +485,32 @@ func TestSqlite_DeleteAccount(t *testing.T) {
|
||||
err = store.(*SqlStore).db.Model(&rpservice.Target{}).Find(&targets, "account_id = ?", account.Id).Error
|
||||
require.NoError(t, err, "expecting no error after DeleteAccount when searching for service targets")
|
||||
require.Len(t, targets, 0, "expecting no service targets to be found after DeleteAccount")
|
||||
|
||||
_, err = store.GetAgentNetworkSettings(context.Background(), LockingStrengthNone, account.Id)
|
||||
require.Error(t, err, "expecting agent network settings to be deleted with the account")
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok, "expecting a status error when getting agent network settings, got %v", err)
|
||||
require.Equal(t, status.NotFound, sErr.Type(), "expecting agent network settings to be deleted with the account")
|
||||
|
||||
// The domain is globally unique, so a leftover row would keep it from another account.
|
||||
err = store.CreateAgentNetworkSettings(context.Background(), &agentNetworkTypes.Settings{
|
||||
AccountID: "other_account",
|
||||
Domain: "gw.example.com",
|
||||
ProxyAddress: "gw.example.com",
|
||||
})
|
||||
require.NoError(t, err, "expecting the deleted account's gateway domain to be free for another account")
|
||||
|
||||
for _, row := range agentNetworkConfig {
|
||||
var count int64
|
||||
err = store.(*SqlStore).db.Model(row).Where("account_id = ?", account.Id).Count(&count).Error
|
||||
require.NoError(t, err, "counting %T rows after DeleteAccount", row)
|
||||
assert.Zero(t, count, "expecting no %T rows to be found after DeleteAccount", row)
|
||||
}
|
||||
|
||||
var otherProviders int64
|
||||
err = store.(*SqlStore).db.Model(&agentNetworkTypes.Provider{}).Where("account_id = ?", "other_account").Count(&otherProviders).Error
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), otherProviders, "expecting another account's agent network provider to survive DeleteAccount")
|
||||
}
|
||||
|
||||
func Test_GetAccount(t *testing.T) {
|
||||
|
||||
@@ -69,6 +69,23 @@ func (s *SqlStore) DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountI
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// GetDeletedAccountIDsWithAgentNetworkAccessLogs returns the IDs of accounts that no
|
||||
// longer exist but still have access-log rows. The retention sweep is driven by settings
|
||||
// rows, which are deleted with the account, so it uses this to find logs it would
|
||||
// otherwise never expire.
|
||||
func (s *SqlStore) GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx context.Context) ([]string, error) {
|
||||
var accountIDs []string
|
||||
err := s.db.Model(&agentNetworkTypes.AgentNetworkAccessLog{}).
|
||||
Distinct("account_id").
|
||||
Where("NOT EXISTS (SELECT 1 FROM accounts WHERE accounts.id = agent_network_access_log.account_id)").
|
||||
Pluck("account_id", &accountIDs).Error
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get deleted accounts with agent-network access logs: %v", err)
|
||||
return nil, status.Errorf(status.Internal, "failed to get deleted accounts with agent-network access logs")
|
||||
}
|
||||
return accountIDs, nil
|
||||
}
|
||||
|
||||
// GetAgentNetworkAccessLogs retrieves flattened agent-network access logs for
|
||||
// an account with server-side pagination, filtering and sorting. Authorising
|
||||
// group ids are hydrated from the group child table for the returned page.
|
||||
|
||||
@@ -663,6 +663,21 @@ func (s *SqlStore) IncrementAgentNetworkConsumptionBatch(
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAgentNetworkConsumptionOfDeletedAccounts deletes every consumption counter whose
|
||||
// account no longer exists and returns the number of rows deleted. Counters grow with
|
||||
// traffic, so they are swept in the background instead of in the account-deletion
|
||||
// transaction, and the sweep also catches counters a proxy writes after the deletion.
|
||||
func (s *SqlStore) DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx context.Context) (int64, error) {
|
||||
res := s.db.
|
||||
Where("NOT EXISTS (SELECT 1 FROM accounts WHERE accounts.id = agent_network_consumption.account_id)").
|
||||
Delete(&agentNetworkTypes.Consumption{})
|
||||
if res.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete agent-network consumption of deleted accounts: %v", res.Error)
|
||||
return 0, status.Errorf(status.Internal, "failed to delete agent-network consumption of deleted accounts")
|
||||
}
|
||||
return res.RowsAffected, nil
|
||||
}
|
||||
|
||||
// ListAgentNetworkConsumption returns every consumption row recorded
|
||||
// for the account, ordered by window_start descending. Backs the
|
||||
// dashboard's basic counter view.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
)
|
||||
|
||||
// TestAgentNetworkUsage_RealStore_RoundTrip drives CreateAgentNetworkUsage and
|
||||
@@ -300,3 +301,37 @@ func TestDeleteOldAgentNetworkAccessLogs(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, usage, 1, "usage record for the deleted log must survive")
|
||||
}
|
||||
|
||||
// TestDeleteAgentNetworkConsumptionOfDeletedAccounts verifies that the sweep removes the
|
||||
// consumption counters of accounts that no longer exist and leaves live accounts' counters,
|
||||
// including those of a live account without a settings row.
|
||||
func TestDeleteAgentNetworkConsumptionOfDeletedAccounts(t *testing.T) {
|
||||
runTestForAllEngines(t, "", func(t *testing.T, s Store) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
liveAccountID = "acc-anet-consumption-live"
|
||||
deletedAccountID = "acc-anet-consumption-deleted"
|
||||
)
|
||||
require.NoError(t, s.SaveAccount(ctx, &types.Account{Id: liveAccountID}))
|
||||
|
||||
windowStart := time.Now().UTC().Truncate(time.Hour)
|
||||
for _, accountID := range []string{liveAccountID, deletedAccountID} {
|
||||
for _, dimID := range []string{"user-1", "user-2"} {
|
||||
require.NoError(t, s.IncrementAgentNetworkConsumption(ctx, accountID,
|
||||
agentNetworkTypes.DimensionUser, dimID, 3600, windowStart, 10, 5, 0.01))
|
||||
}
|
||||
}
|
||||
|
||||
deleted, err := s.DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), deleted, "both of the deleted account's counters should be removed")
|
||||
|
||||
rows, err := s.ListAgentNetworkConsumption(ctx, LockingStrengthNone, deletedAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, rows, "the deleted account should have no consumption counters left")
|
||||
|
||||
rows, err = s.ListAgentNetworkConsumption(ctx, LockingStrengthNone, liveAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, rows, 2, "the live account's consumption counters should survive")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -320,6 +320,7 @@ type Store interface {
|
||||
GetAgentNetworkAccessLogSessions(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkAccessLogSession, int64, error)
|
||||
GetAgentNetworkUsageRows(ctx context.Context, lockStrength LockingStrength, accountID string, filter agentNetworkTypes.AgentNetworkAccessLogFilter) ([]*agentNetworkTypes.AgentNetworkUsage, error)
|
||||
DeleteOldAgentNetworkAccessLogs(ctx context.Context, accountID string, olderThan time.Time) (int64, error)
|
||||
GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx context.Context) ([]string, error)
|
||||
GetServiceTargetByTargetID(ctx context.Context, lockStrength LockingStrength, accountID string, targetID string) (*rpservice.Target, error)
|
||||
GetTargetsByServiceID(ctx context.Context, lockStrength LockingStrength, accountID string, serviceID string) ([]*rpservice.Target, error)
|
||||
DeleteTarget(ctx context.Context, accountID string, serviceID string, targetID uint) error
|
||||
@@ -388,6 +389,7 @@ type Store interface {
|
||||
GetAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string, kind agentNetworkTypes.ConsumptionDimension, dimID string, windowSeconds int64, windowStart time.Time) (*agentNetworkTypes.Consumption, error)
|
||||
GetAgentNetworkConsumptionBatch(ctx context.Context, lockStrength LockingStrength, accountID string, keys []agentNetworkTypes.ConsumptionKey) (map[agentNetworkTypes.ConsumptionKey]*agentNetworkTypes.Consumption, error)
|
||||
ListAgentNetworkConsumption(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.Consumption, error)
|
||||
DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx context.Context) (int64, error)
|
||||
GetAccountAgentNetworkBudgetRules(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*agentNetworkTypes.AccountBudgetRule, error)
|
||||
GetAgentNetworkBudgetRuleByID(ctx context.Context, lockStrength LockingStrength, accountID, ruleID string) (*agentNetworkTypes.AccountBudgetRule, error)
|
||||
SaveAgentNetworkBudgetRule(ctx context.Context, rule *agentNetworkTypes.AccountBudgetRule) error
|
||||
|
||||
@@ -457,6 +457,21 @@ func (mr *MockStoreMockRecorder) DeleteAgentNetworkBudgetRule(ctx, accountID, ru
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkBudgetRule", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkBudgetRule), ctx, accountID, ruleID)
|
||||
}
|
||||
|
||||
// DeleteAgentNetworkConsumptionOfDeletedAccounts mocks base method.
|
||||
func (m *MockStore) DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx context.Context) (int64, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DeleteAgentNetworkConsumptionOfDeletedAccounts", ctx)
|
||||
ret0, _ := ret[0].(int64)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// DeleteAgentNetworkConsumptionOfDeletedAccounts indicates an expected call of DeleteAgentNetworkConsumptionOfDeletedAccounts.
|
||||
func (mr *MockStoreMockRecorder) DeleteAgentNetworkConsumptionOfDeletedAccounts(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAgentNetworkConsumptionOfDeletedAccounts", reflect.TypeOf((*MockStore)(nil).DeleteAgentNetworkConsumptionOfDeletedAccounts), ctx)
|
||||
}
|
||||
|
||||
// DeleteAgentNetworkGuardrail mocks base method.
|
||||
func (m *MockStore) DeleteAgentNetworkGuardrail(ctx context.Context, accountID, guardrailID string) error {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -1972,6 +1987,21 @@ func (mr *MockStoreMockRecorder) GetDNSRecordByID(ctx, lockStrength, accountID,
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDNSRecordByID", reflect.TypeOf((*MockStore)(nil).GetDNSRecordByID), ctx, lockStrength, accountID, zoneID, recordID)
|
||||
}
|
||||
|
||||
// GetDeletedAccountIDsWithAgentNetworkAccessLogs mocks base method.
|
||||
func (m *MockStore) GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx context.Context) ([]string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetDeletedAccountIDsWithAgentNetworkAccessLogs", ctx)
|
||||
ret0, _ := ret[0].([]string)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetDeletedAccountIDsWithAgentNetworkAccessLogs indicates an expected call of GetDeletedAccountIDsWithAgentNetworkAccessLogs.
|
||||
func (mr *MockStoreMockRecorder) GetDeletedAccountIDsWithAgentNetworkAccessLogs(ctx any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDeletedAccountIDsWithAgentNetworkAccessLogs", reflect.TypeOf((*MockStore)(nil).GetDeletedAccountIDsWithAgentNetworkAccessLogs), ctx)
|
||||
}
|
||||
|
||||
// GetEmbeddedProxyPeerIDsByCluster mocks base method.
|
||||
func (m *MockStore) GetEmbeddedProxyPeerIDsByCluster(ctx context.Context, accountID string) (map[string][]string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Reference in New Issue
Block a user