From d4b6d7646c0ff40193dd17d1061ff3d31e5dfce0 Mon Sep 17 00:00:00 2001 From: Givi Khojanashvili Date: Tue, 19 Sep 2023 20:08:40 +0400 Subject: [PATCH] Handle user delete (#1113) Implement user deletion across all IDP-ss. Expires all user peers when the user is deleted. Users are permanently removed from a local store, but in IDP, we remove Netbird attributes for the user untilUserDeleteFromIDPEnabled setting is not enabled. To test, an admin user should remove any additional users. Until the UI incorporates this feature, use a curl DELETE request targeting the /users/ management endpoint. Note that this request only removes user attributes and doesn't trigger a delete from the IDP. To enable user removal from the IdP, set UserDeleteFromIDPEnabled to true in account settings. Until we have a UI for this, make this change directly in the store file. Store the deleted email addresses in encrypted in activity store. --- client/cmd/testutil.go | 2 +- client/internal/engine_test.go | 2 +- management/client/client_test.go | 2 +- management/cmd/management.go | 35 ++++- management/cmd/root.go | 2 + management/server/account.go | 50 +++---- management/server/account_test.go | 2 +- management/server/activity/codes.go | 3 + management/server/activity/event.go | 18 ++- management/server/activity/sqlite/crypt.go | 81 +++++++++++ .../server/activity/sqlite/crypt_test.go | 63 +++++++++ management/server/activity/sqlite/sqlite.go | 126 +++++++++++++++--- .../server/activity/sqlite/sqlite_test.go | 3 +- management/server/config.go | 3 +- management/server/dns_test.go | 2 +- management/server/http/api/generate.sh | 0 management/server/http/api/openapi.yml | 5 + management/server/http/api/types.gen.go | 3 + management/server/http/events_handler.go | 56 ++++++-- management/server/http/events_handler_test.go | 3 + management/server/idp/auth0.go | 50 ++++++- management/server/idp/authentik.go | 35 ++++- management/server/idp/azure.go | 37 +++++ management/server/idp/google_workspace.go | 13 ++ management/server/idp/idp.go | 1 + management/server/idp/keycloak.go | 41 ++++++ management/server/idp/okta.go | 22 +++ management/server/idp/zitadel.go | 53 +++++++- management/server/management_proto_test.go | 2 +- management/server/management_test.go | 2 +- management/server/nameserver_test.go | 2 +- management/server/route_test.go | 2 +- management/server/telemetry/idp_metrics.go | 12 ++ management/server/user.go | 118 +++++++++++++--- management/server/user_test.go | 5 +- 35 files changed, 744 insertions(+), 112 deletions(-) create mode 100644 management/server/activity/sqlite/crypt.go create mode 100644 management/server/activity/sqlite/crypt_test.go mode change 100644 => 100755 management/server/http/api/generate.sh diff --git a/client/cmd/testutil.go b/client/cmd/testutil.go index 678072f0b..6d47021dd 100644 --- a/client/cmd/testutil.go +++ b/client/cmd/testutil.go @@ -76,7 +76,7 @@ func startManagement(t *testing.T, config *mgmt.Config) (*grpc.Server, net.Liste return nil, nil } accountManager, err := mgmt.BuildManager(store, peersUpdateManager, nil, "", "", - eventStore) + eventStore, false) if err != nil { t.Fatal(err) } diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go index 9f17ff36b..ea4a23a8d 100644 --- a/client/internal/engine_test.go +++ b/client/internal/engine_test.go @@ -1049,7 +1049,7 @@ func startManagement(dataDir string) (*grpc.Server, string, error) { return nil, "", err } accountManager, err := server.BuildManager(store, peersUpdateManager, nil, "", "", - eventStore) + eventStore, false) if err != nil { return nil, "", err } diff --git a/management/client/client_test.go b/management/client/client_test.go index deef57329..86c598adb 100644 --- a/management/client/client_test.go +++ b/management/client/client_test.go @@ -61,7 +61,7 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) { peersUpdateManager := mgmt.NewPeersUpdateManager() eventStore := &activity.InMemoryEventStore{} accountManager, err := mgmt.BuildManager(store, peersUpdateManager, nil, "", "", - eventStore) + eventStore, false) if err != nil { t.Fatal(err) } diff --git a/management/cmd/management.go b/management/cmd/management.go index 5c3816715..ca333b931 100644 --- a/management/cmd/management.go +++ b/management/cmd/management.go @@ -31,6 +31,7 @@ import ( "github.com/netbirdio/netbird/encryption" mgmtProto "github.com/netbirdio/netbird/management/proto" "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/activity" "github.com/netbirdio/netbird/management/server/activity/sqlite" httpapi "github.com/netbirdio/netbird/management/server/http" "github.com/netbirdio/netbird/management/server/idp" @@ -142,12 +143,22 @@ var ( if disableSingleAccMode { mgmtSingleAccModeDomain = "" } - eventStore, err := sqlite.NewSQLiteStore(config.Datadir) + eventStore, key, err := initEventStore(config.Datadir, config.DataStoreEncryptionKey) if err != nil { - return err + return fmt.Errorf("failed to initialize database: %s", err) } + + if key != "" { + log.Debugf("update config with activity store key") + config.DataStoreEncryptionKey = key + err := updateMgmtConfig(mgmtConfig, config) + if err != nil { + return fmt.Errorf("failed to write out store encryption key: %s", err) + } + } + accountManager, err := server.BuildManager(store, peersUpdateManager, idpManager, mgmtSingleAccModeDomain, - dnsDomain, eventStore) + dnsDomain, eventStore, userDeleteFromIDPEnabled) if err != nil { return fmt.Errorf("failed to build default manager: %v", err) } @@ -287,6 +298,20 @@ var ( } ) +func initEventStore(dataDir string, key string) (activity.Store, string, error) { + var err error + if key == "" { + log.Debugf("generate new activity store encryption key") + key, err = sqlite.GenerateKey() + if err != nil { + return nil, "", err + } + } + store, err := sqlite.NewSQLiteStore(dataDir, key) + return store, key, err + +} + func notifyStop(msg string) { select { case stopCh <- 1: @@ -440,6 +465,10 @@ func loadMgmtConfig(mgmtConfigPath string) (*server.Config, error) { return loadedConfig, err } +func updateMgmtConfig(path string, config *server.Config) error { + return util.WriteJson(path, config) +} + // OIDCConfigResponse used for parsing OIDC config response type OIDCConfigResponse struct { Issuer string `json:"issuer"` diff --git a/management/cmd/root.go b/management/cmd/root.go index a149841c5..2080a6b29 100644 --- a/management/cmd/root.go +++ b/management/cmd/root.go @@ -24,6 +24,7 @@ var ( disableMetrics bool disableSingleAccMode bool idpSignKeyRefreshEnabled bool + userDeleteFromIDPEnabled bool rootCmd = &cobra.Command{ Use: "netbird-mgmt", @@ -56,6 +57,7 @@ func init() { mgmtCmd.Flags().BoolVar(&disableMetrics, "disable-anonymous-metrics", false, "disables push of anonymous usage metrics to NetBird") mgmtCmd.Flags().StringVar(&dnsDomain, "dns-domain", defaultSingleAccModeDomain, fmt.Sprintf("Domain used for peer resolution. This is appended to the peer's name, e.g. pi-server. %s. Max lenght is 192 characters to allow appending to a peer name with up to 63 characters.", defaultSingleAccModeDomain)) mgmtCmd.Flags().BoolVar(&idpSignKeyRefreshEnabled, "idp-sign-key-refresh-enabled", false, "Enable cache headers evaluation to determine signing key rotation period. This will refresh the signing key upon expiry.") + mgmtCmd.Flags().BoolVar(&userDeleteFromIDPEnabled, "user-delete-from-idp", false, "Allows to delete user from IDP when user is deleted from account") rootCmd.MarkFlagRequired("config") //nolint rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "") diff --git a/management/server/account.go b/management/server/account.go index a0d4568ec..2dba658ec 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -130,6 +130,9 @@ type DefaultAccountManager struct { // dnsDomain is used for peer resolution. This is appended to the peer's name dnsDomain string peerLoginExpiry Scheduler + + // userDeleteFromIDPEnabled allows to delete user from IDP when user is deleted from account + userDeleteFromIDPEnabled bool } // Settings represents Account settings structure that can be modified via API and Dashboard @@ -735,18 +738,19 @@ func (a *Account) UserGroupsRemoveFromPeers(userID string, groups ...string) { // BuildManager creates a new DefaultAccountManager with a provided Store func BuildManager(store Store, peersUpdateManager *PeersUpdateManager, idpManager idp.Manager, - singleAccountModeDomain string, dnsDomain string, eventStore activity.Store, + singleAccountModeDomain string, dnsDomain string, eventStore activity.Store, userDeleteFromIDPEnabled bool, ) (*DefaultAccountManager, error) { am := &DefaultAccountManager{ - Store: store, - peersUpdateManager: peersUpdateManager, - idpManager: idpManager, - ctx: context.Background(), - cacheMux: sync.Mutex{}, - cacheLoading: map[string]chan struct{}{}, - dnsDomain: dnsDomain, - eventStore: eventStore, - peerLoginExpiry: NewDefaultScheduler(), + Store: store, + peersUpdateManager: peersUpdateManager, + idpManager: idpManager, + ctx: context.Background(), + cacheMux: sync.Mutex{}, + cacheLoading: map[string]chan struct{}{}, + dnsDomain: dnsDomain, + eventStore: eventStore, + peerLoginExpiry: NewDefaultScheduler(), + userDeleteFromIDPEnabled: userDeleteFromIDPEnabled, } allAccounts := store.GetAllAccounts() // enable single account mode only if configured by user and number of existing accounts is not grater than 1 @@ -871,33 +875,19 @@ func (am *DefaultAccountManager) peerLoginExpirationJob(accountID string) func() return account.GetNextPeerExpiration() } + expiredPeers := account.GetExpiredPeers() var peerIDs []string - for _, peer := range account.GetExpiredPeers() { - if peer.Status.LoginExpired { - continue - } + for _, peer := range expiredPeers { peerIDs = append(peerIDs, peer.ID) - peer.MarkLoginExpired(true) - account.UpdatePeer(peer) - err = am.Store.SavePeerStatus(account.Id, peer.ID, *peer.Status) - if err != nil { - log.Errorf("failed saving peer status while expiring peer %s", peer.ID) - return account.GetNextPeerExpiration() - } - am.storeEvent(peer.UserID, peer.ID, account.Id, activity.PeerLoginExpired, peer.EventMeta(am.GetDNSDomain())) } log.Debugf("discovered %d peers to expire for account %s", len(peerIDs), account.Id) - if len(peerIDs) != 0 { - // this will trigger peer disconnect from the management service - am.peersUpdateManager.CloseChannels(peerIDs) - err = am.updateAccountPeers(account) - if err != nil { - log.Errorf("failed updating account peers while expiring peers for account %s", accountID) - return account.GetNextPeerExpiration() - } + if err := am.expireAndUpdatePeers(account, expiredPeers); err != nil { + log.Errorf("failed updating account peers while expiring peers for account %s", account.Id) + return account.GetNextPeerExpiration() } + return account.GetNextPeerExpiration() } } diff --git a/management/server/account_test.go b/management/server/account_test.go index 64fd90524..204e98947 100644 --- a/management/server/account_test.go +++ b/management/server/account_test.go @@ -2063,7 +2063,7 @@ func createManager(t *testing.T) (*DefaultAccountManager, error) { return nil, err } eventStore := &activity.InMemoryEventStore{} - return BuildManager(store, NewPeersUpdateManager(), nil, "", "netbird.cloud", eventStore) + return BuildManager(store, NewPeersUpdateManager(), nil, "", "netbird.cloud", eventStore, false) } func createStore(t *testing.T) (Store, error) { diff --git a/management/server/activity/codes.go b/management/server/activity/codes.go index 4de667ded..ce36f520f 100644 --- a/management/server/activity/codes.go +++ b/management/server/activity/codes.go @@ -104,6 +104,8 @@ const ( UserBlocked // UserUnblocked indicates that a user unblocked another user UserUnblocked + // UserDeleted indicates that a user deleted another user + UserDeleted // GroupDeleted indicates that a user deleted group GroupDeleted // UserLoggedInPeer indicates that user logged in their peer with an interactive SSO login @@ -162,6 +164,7 @@ var activityMap = map[Activity]Code{ ServiceUserDeleted: {"Service user deleted", "service.user.delete"}, UserBlocked: {"User blocked", "user.block"}, UserUnblocked: {"User unblocked", "user.unblock"}, + UserDeleted: {"User deleted", "user.delete"}, GroupDeleted: {"Group deleted", "group.delete"}, UserLoggedInPeer: {"User logged in peer", "user.peer.login"}, PeerLoginExpired: {"Peer login expired", "peer.login.expire"}, diff --git a/management/server/activity/event.go b/management/server/activity/event.go index 17ec4a0b0..1bf86ef2c 100644 --- a/management/server/activity/event.go +++ b/management/server/activity/event.go @@ -18,10 +18,13 @@ type Event struct { ID uint64 // InitiatorID is the ID of an object that initiated the event (e.g., a user) InitiatorID string + // InitiatorEmail is the email address of an object that initiated the event. This will be set on deleted users only + InitiatorEmail string // TargetID is the ID of an object that was effected by the event (e.g., a peer) TargetID string // AccountID is the ID of an account where the event happened AccountID string + // Meta of the event, e.g. deleted peer information like name, IP, etc Meta map[string]any } @@ -35,12 +38,13 @@ func (e *Event) Copy() *Event { } return &Event{ - Timestamp: e.Timestamp, - Activity: e.Activity, - ID: e.ID, - InitiatorID: e.InitiatorID, - TargetID: e.TargetID, - AccountID: e.AccountID, - Meta: meta, + Timestamp: e.Timestamp, + Activity: e.Activity, + ID: e.ID, + InitiatorID: e.InitiatorID, + InitiatorEmail: e.InitiatorEmail, + TargetID: e.TargetID, + AccountID: e.AccountID, + Meta: meta, } } diff --git a/management/server/activity/sqlite/crypt.go b/management/server/activity/sqlite/crypt.go new file mode 100644 index 000000000..8f2755604 --- /dev/null +++ b/management/server/activity/sqlite/crypt.go @@ -0,0 +1,81 @@ +package sqlite + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" +) + +var iv = []byte{10, 22, 13, 79, 05, 8, 52, 91, 87, 98, 88, 98, 35, 25, 13, 05} + +type EmailEncrypt struct { + block cipher.Block +} + +func GenerateKey() (string, error) { + key := make([]byte, 32) + _, err := rand.Read(key) + if err != nil { + return "", err + } + readableKey := base64.StdEncoding.EncodeToString(key) + return readableKey, nil +} + +func NewEmailEncrypt(key string) (*EmailEncrypt, error) { + binKey, err := base64.StdEncoding.DecodeString(key) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(binKey) + if err != nil { + return nil, err + } + ec := &EmailEncrypt{ + block: block, + } + + return ec, nil +} + +func (ec *EmailEncrypt) Encrypt(payload string) string { + plainText := pkcs5Padding([]byte(payload)) + cipherText := make([]byte, len(plainText)) + cbc := cipher.NewCBCEncrypter(ec.block, iv) + cbc.CryptBlocks(cipherText, plainText) + return base64.StdEncoding.EncodeToString(cipherText) +} + +func (ec *EmailEncrypt) Decrypt(data string) (string, error) { + cipherText, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return "", err + } + cbc := cipher.NewCBCDecrypter(ec.block, iv) + cbc.CryptBlocks(cipherText, cipherText) + payload, err := pkcs5UnPadding(cipherText) + if err != nil { + return "", err + } + + return string(payload), nil +} + +func pkcs5Padding(ciphertext []byte) []byte { + padding := aes.BlockSize - len(ciphertext)%aes.BlockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + return append(ciphertext, padText...) +} + +func pkcs5UnPadding(src []byte) ([]byte, error) { + srcLen := len(src) + paddingLen := int(src[srcLen-1]) + if paddingLen >= srcLen || paddingLen > aes.BlockSize { + return nil, fmt.Errorf("padding size error") + } + return src[:srcLen-paddingLen], nil +} diff --git a/management/server/activity/sqlite/crypt_test.go b/management/server/activity/sqlite/crypt_test.go new file mode 100644 index 000000000..5fb59a692 --- /dev/null +++ b/management/server/activity/sqlite/crypt_test.go @@ -0,0 +1,63 @@ +package sqlite + +import ( + "testing" +) + +func TestGenerateKey(t *testing.T) { + testData := "exampl@netbird.io" + key, err := GenerateKey() + if err != nil { + t.Fatalf("failed to generate key: %s", err) + } + ee, err := NewEmailEncrypt(key) + if err != nil { + t.Fatalf("failed to init email encryption: %s", err) + } + + encrypted := ee.Encrypt(testData) + if encrypted == "" { + t.Fatalf("invalid encrypted text") + } + + decrypted, err := ee.Decrypt(encrypted) + if err != nil { + t.Fatalf("failed to decrypt data: %s", err) + } + + if decrypted != testData { + t.Fatalf("decrypted data is not match with test data: %s, %s", testData, decrypted) + } +} + +func TestCorruptKey(t *testing.T) { + testData := "exampl@netbird.io" + key, err := GenerateKey() + if err != nil { + t.Fatalf("failed to generate key: %s", err) + } + ee, err := NewEmailEncrypt(key) + if err != nil { + t.Fatalf("failed to init email encryption: %s", err) + } + + encrypted := ee.Encrypt(testData) + if encrypted == "" { + t.Fatalf("invalid encrypted text") + } + + newKey, err := GenerateKey() + if err != nil { + t.Fatalf("failed to generate key: %s", err) + } + + ee, err = NewEmailEncrypt(newKey) + if err != nil { + t.Fatalf("failed to init email encryption: %s", err) + } + + res, err := ee.Decrypt(encrypted) + if err == nil || res == testData { + t.Fatalf("incorrect decryption, the result is: %s", res) + } +} diff --git a/management/server/activity/sqlite/sqlite.go b/management/server/activity/sqlite/sqlite.go index a4c85cf60..7ff59674d 100644 --- a/management/server/activity/sqlite/sqlite.go +++ b/management/server/activity/sqlite/sqlite.go @@ -3,14 +3,14 @@ package sqlite import ( "database/sql" "encoding/json" - - "github.com/netbirdio/netbird/management/server/activity" - - // sqlite driver + "fmt" "path/filepath" "time" - _ "github.com/mattn/go-sqlite3" + _ "github.com/mattn/go-sqlite3" // sqlite driver + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/management/server/activity" ) const ( @@ -25,35 +25,62 @@ const ( "meta TEXT," + " target_id TEXT);" - selectDescQuery = "SELECT id, activity, timestamp, initiator_id, target_id, account_id, meta" + - " FROM events WHERE account_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?;" - selectAscQuery = "SELECT id, activity, timestamp, initiator_id, target_id, account_id, meta" + - " FROM events WHERE account_id = ? ORDER BY timestamp ASC LIMIT ? OFFSET ?;" + creatTableAccountEmailQuery = `CREATE TABLE IF NOT EXISTS deleted_users (id TEXT NOT NULL, email TEXT NOT NULL);` + + selectDescQuery = `SELECT events.id, activity, timestamp, initiator_id, i.email as "initiator_email", target_id, t.email as "target_email", account_id, meta + FROM events + LEFT JOIN deleted_users i ON events.initiator_id = i.id + LEFT JOIN deleted_users t ON events.target_id = t.id + WHERE account_id = ? + ORDER BY timestamp DESC LIMIT ? OFFSET ?;` + + selectAscQuery = `SELECT events.id, activity, timestamp, initiator_id, i.email as "initiator_email", target_id, t.email as "target_email", account_id, meta + FROM events + LEFT JOIN deleted_users i ON events.initiator_id = i.id + LEFT JOIN deleted_users t ON events.target_id = t.id + WHERE account_id = ? + ORDER BY timestamp ASC LIMIT ? OFFSET ?;` + insertQuery = "INSERT INTO events(activity, timestamp, initiator_id, target_id, account_id, meta) " + "VALUES(?, ?, ?, ?, ?, ?)" + + insertDeleteUserQuery = `INSERT INTO deleted_users(id, email) VALUES(?, ?)` ) // Store is the implementation of the activity.Store interface backed by SQLite type Store struct { - db *sql.DB + db *sql.DB + emailEncrypt *EmailEncrypt + insertStatement *sql.Stmt selectAscStatement *sql.Stmt selectDescStatement *sql.Stmt + deleteUserStmt *sql.Stmt } // NewSQLiteStore creates a new Store with an event table if not exists. -func NewSQLiteStore(dataDir string) (*Store, error) { +func NewSQLiteStore(dataDir string, encryptionKey string) (*Store, error) { dbFile := filepath.Join(dataDir, eventSinkDB) db, err := sql.Open("sqlite3", dbFile) if err != nil { return nil, err } + crypt, err := NewEmailEncrypt(encryptionKey) + if err != nil { + return nil, err + } + _, err = db.Exec(createTableQuery) if err != nil { return nil, err } + _, err = db.Exec(creatTableAccountEmailQuery) + if err != nil { + return nil, err + } + insertStmt, err := db.Prepare(insertQuery) if err != nil { return nil, err @@ -69,25 +96,35 @@ func NewSQLiteStore(dataDir string) (*Store, error) { return nil, err } - return &Store{ + deleteUserStmt, err := db.Prepare(insertDeleteUserQuery) + if err != nil { + return nil, err + } + + s := &Store{ db: db, + emailEncrypt: crypt, insertStatement: insertStmt, selectDescStatement: selectDescStmt, selectAscStatement: selectAscStmt, - }, nil + deleteUserStmt: deleteUserStmt, + } + return s, nil } -func processResult(result *sql.Rows) ([]*activity.Event, error) { +func (store *Store) processResult(result *sql.Rows) ([]*activity.Event, error) { events := make([]*activity.Event, 0) for result.Next() { var id int64 var operation activity.Activity var timestamp time.Time var initiator string + var initiatorEmail *string var target string + var targetEmail *string var account string var jsonMeta string - err := result.Scan(&id, &operation, ×tamp, &initiator, &target, &account, &jsonMeta) + err := result.Scan(&id, &operation, ×tamp, &initiator, &initiatorEmail, &target, &targetEmail, &account, &jsonMeta) if err != nil { return nil, err } @@ -100,7 +137,17 @@ func processResult(result *sql.Rows) ([]*activity.Event, error) { } } - events = append(events, &activity.Event{ + if targetEmail != nil { + email, err := store.emailEncrypt.Decrypt(*targetEmail) + if err != nil { + log.Errorf("failed to decrypt email address for target id: %s", target) + meta["email"] = "" + } else { + meta["email"] = email + } + } + + event := &activity.Event{ Timestamp: timestamp, Activity: operation, ID: uint64(id), @@ -108,7 +155,18 @@ func processResult(result *sql.Rows) ([]*activity.Event, error) { TargetID: target, AccountID: account, Meta: meta, - }) + } + + if initiatorEmail != nil { + email, err := store.emailEncrypt.Decrypt(*initiatorEmail) + if err != nil { + log.Errorf("failed to decrypt email address of initiator: %s", initiator) + } else { + event.InitiatorEmail = email + } + } + + events = append(events, event) } return events, nil @@ -127,13 +185,18 @@ func (store *Store) Get(accountID string, offset, limit int, descending bool) ([ } defer result.Close() //nolint - return processResult(result) + return store.processResult(result) } -// Save an event in the SQLite events table +// Save an event in the SQLite events table end encrypt the "email" element in meta map func (store *Store) Save(event *activity.Event) (*activity.Event, error) { var jsonMeta string - if event.Meta != nil { + meta, err := store.saveDeletedUserEmailInEncrypted(event) + if err != nil { + return nil, err + } + + if meta != nil { metaBytes, err := json.Marshal(event.Meta) if err != nil { return nil, err @@ -156,6 +219,29 @@ func (store *Store) Save(event *activity.Event) (*activity.Event, error) { return eventCopy, nil } +// saveDeletedUserEmailInEncrypted if the meta contains email then store it in encrypted way and delete this item from +// meta map +func (store *Store) saveDeletedUserEmailInEncrypted(event *activity.Event) (map[string]any, error) { + email, ok := event.Meta["email"] + if !ok { + return event.Meta, nil + } + + delete(event.Meta, "email") + + encrypted := store.emailEncrypt.Encrypt(fmt.Sprintf("%s", email)) + _, err := store.deleteUserStmt.Exec(event.TargetID, encrypted) + if err != nil { + return nil, err + } + + if len(event.Meta) == 1 { + return nil, nil // nolint + } + delete(event.Meta, "email") + return event.Meta, nil +} + // Close the Store func (store *Store) Close() error { if store.db != nil { diff --git a/management/server/activity/sqlite/sqlite_test.go b/management/server/activity/sqlite/sqlite_test.go index 2ca9a1e64..f6a6f9467 100644 --- a/management/server/activity/sqlite/sqlite_test.go +++ b/management/server/activity/sqlite/sqlite_test.go @@ -12,7 +12,8 @@ import ( func TestNewSQLiteStore(t *testing.T) { dataDir := t.TempDir() - store, err := NewSQLiteStore(dataDir) + key, _ := GenerateKey() + store, err := NewSQLiteStore(dataDir, key) if err != nil { t.Fatal(err) return diff --git a/management/server/config.go b/management/server/config.go index ea0143988..31c1cf45c 100644 --- a/management/server/config.go +++ b/management/server/config.go @@ -35,7 +35,8 @@ type Config struct { TURNConfig *TURNConfig Signal *Host - Datadir string + Datadir string + DataStoreEncryptionKey string HttpConfig *HttpServerConfig diff --git a/management/server/dns_test.go b/management/server/dns_test.go index 092c52afa..b089949b2 100644 --- a/management/server/dns_test.go +++ b/management/server/dns_test.go @@ -191,7 +191,7 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) { return nil, err } eventStore := &activity.InMemoryEventStore{} - return BuildManager(store, NewPeersUpdateManager(), nil, "", "netbird.test", eventStore) + return BuildManager(store, NewPeersUpdateManager(), nil, "", "netbird.test", eventStore, false) } func createDNSStore(t *testing.T) (Store, error) { diff --git a/management/server/http/api/generate.sh b/management/server/http/api/generate.sh old mode 100644 new mode 100755 diff --git a/management/server/http/api/openapi.yml b/management/server/http/api/openapi.yml index 06da0ede3..f2d1e26bf 100644 --- a/management/server/http/api/openapi.yml +++ b/management/server/http/api/openapi.yml @@ -922,6 +922,10 @@ components: description: The ID of the initiator of the event. E.g., an ID of a user that triggered the event. type: string example: google-oauth2|123456789012345678901 + initiator_email: + description: The e-mail address of the initiator of the event. E.g., an e-mail of a user that triggered the event. + type: string + example: demo@netbird.io target_id: description: The ID of the target of the event. E.g., an ID of the peer that a user removed. type: string @@ -938,6 +942,7 @@ components: - activity - activity_code - initiator_id + - initiator_email - target_id - meta responses: diff --git a/management/server/http/api/types.gen.go b/management/server/http/api/types.gen.go index 402aae635..33c935a68 100644 --- a/management/server/http/api/types.gen.go +++ b/management/server/http/api/types.gen.go @@ -164,6 +164,9 @@ type Event struct { // Id Event unique identifier Id string `json:"id"` + // InitiatorEmail The e-mail address of the initiator of the event. E.g., an e-mail of a user that triggered the event. + InitiatorEmail string `json:"initiator_email"` + // InitiatorId The ID of the initiator of the event. E.g., an ID of a user that triggered the event. InitiatorId string `json:"initiator_id"` diff --git a/management/server/http/events_handler.go b/management/server/http/events_handler.go index 1d1c176e5..cbca44364 100644 --- a/management/server/http/events_handler.go +++ b/management/server/http/events_handler.go @@ -45,14 +45,46 @@ func (h *EventsHandler) GetAllEvents(w http.ResponseWriter, r *http.Request) { util.WriteError(err, w) return } - events := make([]*api.Event, 0) - for _, e := range accountEvents { - events = append(events, toEventResponse(e)) + events := make([]*api.Event, len(accountEvents)) + for i, e := range accountEvents { + events[i] = toEventResponse(e) + } + + err = h.fillEventsWithInitiatorEmail(events, account.Id, user.Id) + if err != nil { + util.WriteError(err, w) + return } util.WriteJSONObject(w, events) } +func (h *EventsHandler) fillEventsWithInitiatorEmail(events []*api.Event, accountId, userId string) error { + // build email map based on users + userInfos, err := h.accountManager.GetUsersFromAccount(accountId, userId) + if err != nil { + log.Errorf("failed to get users from account: %s", err) + return err + } + + emails := make(map[string]string) + for _, ui := range userInfos { + emails[ui.ID] = ui.Email + } + + // fill event with email of initiator + var ok bool + for _, event := range events { + if event.InitiatorEmail == "" { + event.InitiatorEmail, ok = emails[event.InitiatorId] + if !ok { + log.Warnf("failed to resolve email for initiator: %s", event.InitiatorId) + } + } + } + return nil +} + func toEventResponse(event *activity.Event) *api.Event { meta := make(map[string]string) if event.Meta != nil { @@ -60,13 +92,15 @@ func toEventResponse(event *activity.Event) *api.Event { meta[s] = fmt.Sprintf("%v", a) } } - return &api.Event{ - Id: fmt.Sprint(event.ID), - InitiatorId: event.InitiatorID, - Activity: event.Activity.Message(), - ActivityCode: api.EventActivityCode(event.Activity.StringCode()), - TargetId: event.TargetID, - Timestamp: event.Timestamp, - Meta: meta, + e := &api.Event{ + Id: fmt.Sprint(event.ID), + InitiatorId: event.InitiatorID, + InitiatorEmail: event.InitiatorEmail, + Activity: event.Activity.Message(), + ActivityCode: api.EventActivityCode(event.Activity.StringCode()), + TargetId: event.TargetID, + Timestamp: event.Timestamp, + Meta: meta, } + return e } diff --git a/management/server/http/events_handler_test.go b/management/server/http/events_handler_test.go index a77e44f45..4cfad922b 100644 --- a/management/server/http/events_handler_test.go +++ b/management/server/http/events_handler_test.go @@ -37,6 +37,9 @@ func initEventsTestData(account string, user *server.User, events ...*activity.E }, }, user, nil }, + GetUsersFromAccountFunc: func(accountID, userID string) ([]*server.UserInfo, error) { + return make([]*server.UserInfo, 0), nil + }, }, claimsExtractor: jwtclaims.NewClaimsExtractor( jwtclaims.WithFromRequestContext(func(r *http.Request) jwtclaims.AuthorizationClaims { diff --git a/management/server/idp/auth0.go b/management/server/idp/auth0.go index 64ec88e9f..d3802d8ad 100644 --- a/management/server/idp/auth0.go +++ b/management/server/idp/auth0.go @@ -513,7 +513,9 @@ func buildUserExportRequest() (string, error) { return string(str), nil } -func (am *Auth0Manager) createPostRequest(endpoint string, payloadStr string) (*http.Request, error) { +func (am *Auth0Manager) createRequest( + method string, endpoint string, body io.Reader, +) (*http.Request, error) { jwtToken, err := am.credentials.Authenticate() if err != nil { return nil, err @@ -521,17 +523,23 @@ func (am *Auth0Manager) createPostRequest(endpoint string, payloadStr string) (* reqURL := am.authIssuer + endpoint - payload := strings.NewReader(payloadStr) - - req, err := http.NewRequest("POST", reqURL, payload) + req, err := http.NewRequest(method, reqURL, body) if err != nil { return nil, err } req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken) + + return req, nil +} + +func (am *Auth0Manager) createPostRequest(endpoint string, payloadStr string) (*http.Request, error) { + req, err := am.createRequest("POST", endpoint, strings.NewReader(payloadStr)) + if err != nil { + return nil, err + } req.Header.Add("content-type", "application/json") return req, nil - } // GetAllAccounts gets all registered accounts with corresponding user data. @@ -737,6 +745,38 @@ func (am *Auth0Manager) InviteUserByID(userID string) error { return nil } +// DeleteUser from Auth0 +func (am *Auth0Manager) DeleteUser(userID string) error { + req, err := am.createRequest(http.MethodDelete, "/api/v2/users/"+url.QueryEscape(userID), nil) + if err != nil { + return err + } + + resp, err := am.httpClient.Do(req) + if err != nil { + log.Debugf("execute delete request: %v", err) + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountRequestError() + } + return err + } + + defer func() { + err = resp.Body.Close() + if err != nil { + log.Errorf("close delete request body: %v", err) + } + }() + if resp.StatusCode != 204 { + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountRequestStatusError() + } + return fmt.Errorf("unable to delete user, statusCode %d", resp.StatusCode) + } + + return nil +} + // checkExportJobStatus checks the status of the job created at CreateExportUsersJob. // If the status is "completed", then return the downloadLink func (am *Auth0Manager) checkExportJobStatus(jobID string) (bool, string, error) { diff --git a/management/server/idp/authentik.go b/management/server/idp/authentik.go index 0898f1c94..102222d0d 100644 --- a/management/server/idp/authentik.go +++ b/management/server/idp/authentik.go @@ -12,9 +12,10 @@ import ( "time" "github.com/golang-jwt/jwt" - "github.com/netbirdio/netbird/management/server/telemetry" log "github.com/sirupsen/logrus" "goauthentik.io/api/v3" + + "github.com/netbirdio/netbird/management/server/telemetry" ) // AuthentikManager authentik manager client instance. @@ -453,6 +454,38 @@ func (am *AuthentikManager) InviteUserByID(_ string) error { return fmt.Errorf("method InviteUserByID not implemented") } +// DeleteUser from Authentik +func (am *AuthentikManager) DeleteUser(userID string) error { + ctx, err := am.authenticationContext() + if err != nil { + return err + } + + userPk, err := strconv.ParseInt(userID, 10, 32) + if err != nil { + return err + } + + resp, err := am.apiClient.CoreApi.CoreUsersDestroy(ctx, int32(userPk)).Execute() + if err != nil { + return err + } + defer resp.Body.Close() // nolint + + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountDeleteUser() + } + + if resp.StatusCode != http.StatusNoContent { + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountRequestStatusError() + } + return fmt.Errorf("unable to delete user %s, statusCode %d", userID, resp.StatusCode) + } + + return nil +} + func (am *AuthentikManager) authenticationContext() (context.Context, error) { jwtToken, err := am.credentials.Authenticate() if err != nil { diff --git a/management/server/idp/azure.go b/management/server/idp/azure.go index 7cff7d8fc..22e6825ae 100644 --- a/management/server/idp/azure.go +++ b/management/server/idp/azure.go @@ -454,6 +454,43 @@ func (am *AzureManager) InviteUserByID(_ string) error { return fmt.Errorf("method InviteUserByID not implemented") } +// DeleteUser from Azure +func (am *AzureManager) DeleteUser(userID string) error { + jwtToken, err := am.credentials.Authenticate() + if err != nil { + return err + } + + reqURL := fmt.Sprintf("%s/users/%s", am.GraphAPIEndpoint, url.QueryEscape(userID)) + req, err := http.NewRequest(http.MethodDelete, reqURL, nil) + if err != nil { + return err + } + req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken) + req.Header.Add("content-type", "application/json") + + log.Debugf("delete idp user %s", userID) + + resp, err := am.httpClient.Do(req) + if err != nil { + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountRequestError() + } + return err + } + defer resp.Body.Close() + + if am.appMetrics != nil { + am.appMetrics.IDPMetrics().CountDeleteUser() + } + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("unable to delete user, statusCode %d", resp.StatusCode) + } + + return nil +} + func (am *AzureManager) getUserExtensions() ([]azureExtension, error) { q := url.Values{} q.Add("$select", extensionFields) diff --git a/management/server/idp/google_workspace.go b/management/server/idp/google_workspace.go index 2e65497dc..40854e598 100644 --- a/management/server/idp/google_workspace.go +++ b/management/server/idp/google_workspace.go @@ -254,6 +254,19 @@ func (gm *GoogleWorkspaceManager) InviteUserByID(_ string) error { return fmt.Errorf("method InviteUserByID not implemented") } +// DeleteUser from GoogleWorkspace. +func (gm *GoogleWorkspaceManager) DeleteUser(userID string) error { + if err := gm.usersService.Delete(userID).Do(); err != nil { + return err + } + + if gm.appMetrics != nil { + gm.appMetrics.IDPMetrics().CountDeleteUser() + } + + return nil +} + // getGoogleCredentials retrieves Google credentials based on the provided serviceAccountKey. // It decodes the base64-encoded serviceAccountKey and attempts to obtain credentials using it. // If that fails, it falls back to using the default Google credentials path. diff --git a/management/server/idp/idp.go b/management/server/idp/idp.go index 3c1f4c327..ea2231390 100644 --- a/management/server/idp/idp.go +++ b/management/server/idp/idp.go @@ -18,6 +18,7 @@ type Manager interface { CreateUser(email, name, accountID, invitedByEmail string) (*UserData, error) GetUserByEmail(email string) ([]*UserData, error) InviteUserByID(userID string) error + DeleteUser(userID string) error } // ClientConfig defines common client configuration for all IdP manager diff --git a/management/server/idp/keycloak.go b/management/server/idp/keycloak.go index 12ed87389..d65a78ae3 100644 --- a/management/server/idp/keycloak.go +++ b/management/server/idp/keycloak.go @@ -467,6 +467,47 @@ func (km *KeycloakManager) InviteUserByID(_ string) error { return fmt.Errorf("method InviteUserByID not implemented") } +// DeleteUser from Keycloack +func (km *KeycloakManager) DeleteUser(userID string) error { + jwtToken, err := km.credentials.Authenticate() + if err != nil { + return err + } + + reqURL := fmt.Sprintf("%s/users/%s", km.adminEndpoint, url.QueryEscape(userID)) + + req, err := http.NewRequest(http.MethodDelete, reqURL, nil) + if err != nil { + return err + } + req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken) + req.Header.Add("content-type", "application/json") + + if km.appMetrics != nil { + km.appMetrics.IDPMetrics().CountDeleteUser() + } + + resp, err := km.httpClient.Do(req) + if err != nil { + if km.appMetrics != nil { + km.appMetrics.IDPMetrics().CountRequestError() + } + return err + } + defer resp.Body.Close() // nolint + + // In the docs, they specified 200, but in the endpoints, they return 204 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + if km.appMetrics != nil { + km.appMetrics.IDPMetrics().CountRequestStatusError() + } + + return fmt.Errorf("unable to delete user, statusCode %d", resp.StatusCode) + } + + return nil +} + func buildKeycloakCreateUserRequestPayload(email string, name string, appMetadata AppMetadata) (string, error) { attrs := keycloakUserAttributes{} attrs.Set(wtAccountID, appMetadata.WTAccountID) diff --git a/management/server/idp/okta.go b/management/server/idp/okta.go index c6b5055d4..0e93c494c 100644 --- a/management/server/idp/okta.go +++ b/management/server/idp/okta.go @@ -319,6 +319,28 @@ func (om *OktaManager) InviteUserByID(_ string) error { return fmt.Errorf("method InviteUserByID not implemented") } +// DeleteUser from Okta +func (om *OktaManager) DeleteUser(userID string) error { + resp, err := om.client.User.DeactivateOrDeleteUser(context.Background(), userID, nil) + if err != nil { + fmt.Println(err.Error()) + return err + } + + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountDeleteUser() + } + + if resp.StatusCode != http.StatusOK { + if om.appMetrics != nil { + om.appMetrics.IDPMetrics().CountRequestStatusError() + } + return fmt.Errorf("unable to delete user, statusCode %d", resp.StatusCode) + } + + return nil +} + // updateUserProfileSchema updates the Okta user schema to include custom fields, // wt_account_id and wt_pending_invite. func updateUserProfileSchema(client *okta.Client) error { diff --git a/management/server/idp/zitadel.go b/management/server/idp/zitadel.go index fce2c7b37..73958a69e 100644 --- a/management/server/idp/zitadel.go +++ b/management/server/idp/zitadel.go @@ -428,7 +428,7 @@ func (zm *ZitadelManager) UpdateUserAppMetadata(userID string, appMetadata AppMe return err } - resource := fmt.Sprintf("users/%s/metadata/_bulk", userID) + resource := fmt.Sprintf("users/%s", userID) _, err = zm.post(resource, string(payload)) if err != nil { return err @@ -447,6 +447,21 @@ func (zm *ZitadelManager) InviteUserByID(_ string) error { return fmt.Errorf("method InviteUserByID not implemented") } +// DeleteUser from Zitadel +func (zm *ZitadelManager) DeleteUser(userID string) error { + resource := fmt.Sprintf("users/%s", userID) + if err := zm.delete(resource); err != nil { + return err + } + + if zm.appMetrics != nil { + zm.appMetrics.IDPMetrics().CountDeleteUser() + } + + return nil + +} + // getUserMetadata requests user metadata from zitadel via ID. func (zm *ZitadelManager) getUserMetadata(userID string) ([]zitadelMetadata, error) { resource := fmt.Sprintf("users/%s/metadata/_search", userID) @@ -500,6 +515,42 @@ func (zm *ZitadelManager) post(resource string, body string) ([]byte, error) { return io.ReadAll(resp.Body) } +// delete perform Delete requests. +func (zm *ZitadelManager) delete(resource string) error { + jwtToken, err := zm.credentials.Authenticate() + if err != nil { + return err + } + + reqURL := fmt.Sprintf("%s/%s", zm.managementEndpoint, resource) + req, err := http.NewRequest(http.MethodDelete, reqURL, nil) + if err != nil { + return err + } + req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken) + req.Header.Add("content-type", "application/json") + + resp, err := zm.httpClient.Do(req) + if err != nil { + if zm.appMetrics != nil { + zm.appMetrics.IDPMetrics().CountRequestError() + } + + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + if zm.appMetrics != nil { + zm.appMetrics.IDPMetrics().CountRequestStatusError() + } + + return fmt.Errorf("unable to delete %s, statusCode %d", reqURL, resp.StatusCode) + } + + return nil +} + // get perform Get requests. func (zm *ZitadelManager) get(resource string, q url.Values) ([]byte, error) { jwtToken, err := zm.credentials.Authenticate() diff --git a/management/server/management_proto_test.go b/management/server/management_proto_test.go index 66661dbf8..b4a527e46 100644 --- a/management/server/management_proto_test.go +++ b/management/server/management_proto_test.go @@ -412,7 +412,7 @@ func startManagement(t *testing.T, config *Config) (*grpc.Server, string, error) peersUpdateManager := NewPeersUpdateManager() eventStore := &activity.InMemoryEventStore{} accountManager, err := BuildManager(store, peersUpdateManager, nil, "", "", - eventStore) + eventStore, false) if err != nil { return nil, "", err } diff --git a/management/server/management_test.go b/management/server/management_test.go index 6c93765f4..fa35cfdef 100644 --- a/management/server/management_test.go +++ b/management/server/management_test.go @@ -503,7 +503,7 @@ func startServer(config *server.Config) (*grpc.Server, net.Listener) { peersUpdateManager := server.NewPeersUpdateManager() eventStore := &activity.InMemoryEventStore{} accountManager, err := server.BuildManager(store, peersUpdateManager, nil, "", "", - eventStore) + eventStore, false) if err != nil { log.Fatalf("failed creating a manager: %v", err) } diff --git a/management/server/nameserver_test.go b/management/server/nameserver_test.go index ab3edaed4..26977116b 100644 --- a/management/server/nameserver_test.go +++ b/management/server/nameserver_test.go @@ -744,7 +744,7 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) { return nil, err } eventStore := &activity.InMemoryEventStore{} - return BuildManager(store, NewPeersUpdateManager(), nil, "", "", eventStore) + return BuildManager(store, NewPeersUpdateManager(), nil, "", "", eventStore, false) } func createNSStore(t *testing.T) (Store, error) { diff --git a/management/server/route_test.go b/management/server/route_test.go index 69241da31..81ce21a3f 100644 --- a/management/server/route_test.go +++ b/management/server/route_test.go @@ -681,7 +681,7 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, error) { return nil, err } eventStore := &activity.InMemoryEventStore{} - return BuildManager(store, NewPeersUpdateManager(), nil, "", "", eventStore) + return BuildManager(store, NewPeersUpdateManager(), nil, "", "", eventStore, false) } func createRouterStore(t *testing.T) (Store, error) { diff --git a/management/server/telemetry/idp_metrics.go b/management/server/telemetry/idp_metrics.go index 67a1d9e85..e9eee17bd 100644 --- a/management/server/telemetry/idp_metrics.go +++ b/management/server/telemetry/idp_metrics.go @@ -2,6 +2,7 @@ package telemetry import ( "context" + "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/metric/instrument" "go.opentelemetry.io/otel/metric/instrument/syncint64" @@ -13,6 +14,7 @@ type IDPMetrics struct { getUserByEmailCounter syncint64.Counter getAllAccountsCounter syncint64.Counter createUserCounter syncint64.Counter + deleteUserCounter syncint64.Counter getAccountCounter syncint64.Counter getUserByIDCounter syncint64.Counter authenticateRequestCounter syncint64.Counter @@ -39,6 +41,10 @@ func NewIDPMetrics(ctx context.Context, meter metric.Meter) (*IDPMetrics, error) if err != nil { return nil, err } + deleteUserCounter, err := meter.SyncInt64().Counter("management.idp.delete.user.counter", instrument.WithUnit("1")) + if err != nil { + return nil, err + } getAccountCounter, err := meter.SyncInt64().Counter("management.idp.get.account.counter", instrument.WithUnit("1")) if err != nil { return nil, err @@ -65,6 +71,7 @@ func NewIDPMetrics(ctx context.Context, meter metric.Meter) (*IDPMetrics, error) getUserByEmailCounter: getUserByEmailCounter, getAllAccountsCounter: getAllAccountsCounter, createUserCounter: createUserCounter, + deleteUserCounter: deleteUserCounter, getAccountCounter: getAccountCounter, getUserByIDCounter: getUserByIDCounter, authenticateRequestCounter: authenticateRequestCounter, @@ -88,6 +95,11 @@ func (idpMetrics *IDPMetrics) CountCreateUser() { idpMetrics.createUserCounter.Add(idpMetrics.ctx, 1) } +// CountDeleteUser ... +func (idpMetrics *IDPMetrics) CountDeleteUser() { + idpMetrics.deleteUserCounter.Add(idpMetrics.ctx, 1) +} + // CountGetAllAccounts ... func (idpMetrics *IDPMetrics) CountGetAllAccounts() { idpMetrics.getAllAccountsCounter.Add(idpMetrics.ctx, 1) diff --git a/management/server/user.go b/management/server/user.go index 8ee036df7..ebebe1e0f 100644 --- a/management/server/user.go +++ b/management/server/user.go @@ -327,15 +327,43 @@ func (am *DefaultAccountManager) DeleteUser(accountID, initiatorUserID string, t return status.Errorf(status.NotFound, "user not found") } if executingUser.Role != UserRoleAdmin { - return status.Errorf(status.PermissionDenied, "only admins can delete service users") + return status.Errorf(status.PermissionDenied, "only admins can delete users") } - if !targetUser.IsServiceUser { - return status.Errorf(status.PermissionDenied, "regular users can not be deleted") + peers, err := account.FindUserPeers(targetUserID) + if err != nil { + return status.Errorf(status.Internal, "failed to find user peers") } - meta := map[string]any{"name": targetUser.ServiceUserName} - am.storeEvent(initiatorUserID, targetUserID, accountID, activity.ServiceUserDeleted, meta) + if err := am.expireAndUpdatePeers(account, peers); err != nil { + log.Errorf("failed update deleted peers expiration: %s", err) + return err + } + + targetUserEmail, err := am.getEmailOfTargetUser(account.Id, initiatorUserID, targetUserID) + if err != nil { + log.Errorf("failed to resolve email address: %s", err) + return err + } + + var meta map[string]any + var eventAction activity.Activity + if targetUser.IsServiceUser { + meta = map[string]any{"name": targetUser.ServiceUserName} + eventAction = activity.ServiceUserDeleted + } else { + meta = map[string]any{"email": targetUserEmail} + eventAction = activity.UserDeleted + + } + am.storeEvent(initiatorUserID, targetUserID, accountID, eventAction, meta) + + if !isNil(am.idpManager) { + err := am.deleteUserFromIDP(targetUserID, accountID) + if err != nil { + return err + } + } delete(account.Users, targetUserID) @@ -609,23 +637,10 @@ func (am *DefaultAccountManager) SaveUser(accountID, initiatorUserID string, upd if err != nil { return nil, err } - var peerIDs []string - for _, peer := range blockedPeers { - peerIDs = append(peerIDs, peer.ID) - peer.MarkLoginExpired(true) - account.UpdatePeer(peer) - err = am.Store.SavePeerStatus(account.Id, peer.ID, *peer.Status) - if err != nil { - log.Errorf("failed saving peer status while expiring peer %s", peer.ID) - return nil, err - } - } - am.peersUpdateManager.CloseChannels(peerIDs) - err = am.updateAccountPeers(account) - if err != nil { - log.Errorf("failed updating account peers while expiring peers of a blocked user %s", accountID) - return nil, err + if err := am.expireAndUpdatePeers(account, blockedPeers); err != nil { + log.Errorf("failed update expired peers: %s", err) + return nil, err } } @@ -814,6 +829,67 @@ func (am *DefaultAccountManager) GetUsersFromAccount(accountID, userID string) ( return userInfos, nil } +// expireAndUpdatePeers expires all peers of the given user and updates them in the account +func (am *DefaultAccountManager) expireAndUpdatePeers(account *Account, peers []*Peer) error { + var peerIDs []string + for _, peer := range peers { + peerIDs = append(peerIDs, peer.ID) + peer.MarkLoginExpired(true) + account.UpdatePeer(peer) + if err := am.Store.SavePeerStatus(account.Id, peer.ID, *peer.Status); err != nil { + return err + } + am.storeEvent( + peer.UserID, peer.ID, account.Id, + activity.PeerLoginExpired, peer.EventMeta(am.GetDNSDomain()), + ) + } + + if len(peerIDs) != 0 { + // this will trigger peer disconnect from the management service + am.peersUpdateManager.CloseChannels(peerIDs) + if err := am.updateAccountPeers(account); err != nil { + return err + } + } + return nil +} + +func (am *DefaultAccountManager) deleteUserFromIDP(targetUserID, accountID string) error { + if am.userDeleteFromIDPEnabled { + log.Debugf("user %s deleted from IdP", targetUserID) + err := am.idpManager.DeleteUser(targetUserID) + if err != nil { + return fmt.Errorf("failed to delete user %s from IdP: %s", targetUserID, err) + } + } else { + err := am.idpManager.UpdateUserAppMetadata(targetUserID, idp.AppMetadata{}) + if err != nil { + return fmt.Errorf("failed to remove user %s app metadata in IdP: %s", targetUserID, err) + } + + _, err = am.refreshCache(accountID) + if err != nil { + log.Errorf("refresh account (%q) cache: %v", accountID, err) + } + } + return nil +} + +func (am *DefaultAccountManager) getEmailOfTargetUser(accountId string, initiatorId, targetId string) (string, error) { + userInfos, err := am.GetUsersFromAccount(accountId, initiatorId) + if err != nil { + return "", err + } + for _, ui := range userInfos { + if ui.ID == targetId { + return ui.Email, nil + } + } + + return "", fmt.Errorf("email not found for user: %s", targetId) +} + func findUserInIDPUserdata(userID string, userData []*idp.UserData) (*idp.UserData, bool) { for _, user := range userData { if user.ID == userID { diff --git a/management/server/user_test.go b/management/server/user_test.go index b07154663..bd64074b9 100644 --- a/management/server/user_test.go +++ b/management/server/user_test.go @@ -439,8 +439,9 @@ func TestUser_DeleteUser_regularUser(t *testing.T) { } err = am.DeleteUser(mockAccountID, mockUserID, mockUserID) - - assert.Errorf(t, err, "Regular users can not be deleted (yet)") + if err != nil { + t.Errorf("unexpected error: %s", err) + } } func TestDefaultAccountManager_GetUser(t *testing.T) {