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) {