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/<USER_ID> 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.
This commit is contained in:
Givi Khojanashvili 2023-09-19 20:08:40 +04:00 committed by GitHub
parent 8febab4076
commit d4b6d7646c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 744 additions and 112 deletions

View File

@ -76,7 +76,7 @@ func startManagement(t *testing.T, config *mgmt.Config) (*grpc.Server, net.Liste
return nil, nil return nil, nil
} }
accountManager, err := mgmt.BuildManager(store, peersUpdateManager, nil, "", "", accountManager, err := mgmt.BuildManager(store, peersUpdateManager, nil, "", "",
eventStore) eventStore, false)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -1049,7 +1049,7 @@ func startManagement(dataDir string) (*grpc.Server, string, error) {
return nil, "", err return nil, "", err
} }
accountManager, err := server.BuildManager(store, peersUpdateManager, nil, "", "", accountManager, err := server.BuildManager(store, peersUpdateManager, nil, "", "",
eventStore) eventStore, false)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }

View File

@ -61,7 +61,7 @@ func startManagement(t *testing.T) (*grpc.Server, net.Listener) {
peersUpdateManager := mgmt.NewPeersUpdateManager() peersUpdateManager := mgmt.NewPeersUpdateManager()
eventStore := &activity.InMemoryEventStore{} eventStore := &activity.InMemoryEventStore{}
accountManager, err := mgmt.BuildManager(store, peersUpdateManager, nil, "", "", accountManager, err := mgmt.BuildManager(store, peersUpdateManager, nil, "", "",
eventStore) eventStore, false)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -31,6 +31,7 @@ import (
"github.com/netbirdio/netbird/encryption" "github.com/netbirdio/netbird/encryption"
mgmtProto "github.com/netbirdio/netbird/management/proto" mgmtProto "github.com/netbirdio/netbird/management/proto"
"github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server"
"github.com/netbirdio/netbird/management/server/activity"
"github.com/netbirdio/netbird/management/server/activity/sqlite" "github.com/netbirdio/netbird/management/server/activity/sqlite"
httpapi "github.com/netbirdio/netbird/management/server/http" httpapi "github.com/netbirdio/netbird/management/server/http"
"github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/idp"
@ -142,12 +143,22 @@ var (
if disableSingleAccMode { if disableSingleAccMode {
mgmtSingleAccModeDomain = "" mgmtSingleAccModeDomain = ""
} }
eventStore, err := sqlite.NewSQLiteStore(config.Datadir) eventStore, key, err := initEventStore(config.Datadir, config.DataStoreEncryptionKey)
if err != nil { 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, accountManager, err := server.BuildManager(store, peersUpdateManager, idpManager, mgmtSingleAccModeDomain,
dnsDomain, eventStore) dnsDomain, eventStore, userDeleteFromIDPEnabled)
if err != nil { if err != nil {
return fmt.Errorf("failed to build default manager: %v", err) 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) { func notifyStop(msg string) {
select { select {
case stopCh <- 1: case stopCh <- 1:
@ -440,6 +465,10 @@ func loadMgmtConfig(mgmtConfigPath string) (*server.Config, error) {
return loadedConfig, err return loadedConfig, err
} }
func updateMgmtConfig(path string, config *server.Config) error {
return util.WriteJson(path, config)
}
// OIDCConfigResponse used for parsing OIDC config response // OIDCConfigResponse used for parsing OIDC config response
type OIDCConfigResponse struct { type OIDCConfigResponse struct {
Issuer string `json:"issuer"` Issuer string `json:"issuer"`

View File

@ -24,6 +24,7 @@ var (
disableMetrics bool disableMetrics bool
disableSingleAccMode bool disableSingleAccMode bool
idpSignKeyRefreshEnabled bool idpSignKeyRefreshEnabled bool
userDeleteFromIDPEnabled bool
rootCmd = &cobra.Command{ rootCmd = &cobra.Command{
Use: "netbird-mgmt", 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().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().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(&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.MarkFlagRequired("config") //nolint
rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "") rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "")

View File

@ -130,6 +130,9 @@ type DefaultAccountManager struct {
// dnsDomain is used for peer resolution. This is appended to the peer's name // dnsDomain is used for peer resolution. This is appended to the peer's name
dnsDomain string dnsDomain string
peerLoginExpiry Scheduler 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 // 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 // BuildManager creates a new DefaultAccountManager with a provided Store
func BuildManager(store Store, peersUpdateManager *PeersUpdateManager, idpManager idp.Manager, 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) { ) (*DefaultAccountManager, error) {
am := &DefaultAccountManager{ am := &DefaultAccountManager{
Store: store, Store: store,
peersUpdateManager: peersUpdateManager, peersUpdateManager: peersUpdateManager,
idpManager: idpManager, idpManager: idpManager,
ctx: context.Background(), ctx: context.Background(),
cacheMux: sync.Mutex{}, cacheMux: sync.Mutex{},
cacheLoading: map[string]chan struct{}{}, cacheLoading: map[string]chan struct{}{},
dnsDomain: dnsDomain, dnsDomain: dnsDomain,
eventStore: eventStore, eventStore: eventStore,
peerLoginExpiry: NewDefaultScheduler(), peerLoginExpiry: NewDefaultScheduler(),
userDeleteFromIDPEnabled: userDeleteFromIDPEnabled,
} }
allAccounts := store.GetAllAccounts() allAccounts := store.GetAllAccounts()
// enable single account mode only if configured by user and number of existing accounts is not grater than 1 // 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() return account.GetNextPeerExpiration()
} }
expiredPeers := account.GetExpiredPeers()
var peerIDs []string var peerIDs []string
for _, peer := range account.GetExpiredPeers() { for _, peer := range expiredPeers {
if peer.Status.LoginExpired {
continue
}
peerIDs = append(peerIDs, peer.ID) 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) log.Debugf("discovered %d peers to expire for account %s", len(peerIDs), account.Id)
if len(peerIDs) != 0 { if err := am.expireAndUpdatePeers(account, expiredPeers); err != nil {
// this will trigger peer disconnect from the management service log.Errorf("failed updating account peers while expiring peers for account %s", account.Id)
am.peersUpdateManager.CloseChannels(peerIDs) return account.GetNextPeerExpiration()
err = am.updateAccountPeers(account)
if err != nil {
log.Errorf("failed updating account peers while expiring peers for account %s", accountID)
return account.GetNextPeerExpiration()
}
} }
return account.GetNextPeerExpiration() return account.GetNextPeerExpiration()
} }
} }

View File

@ -2063,7 +2063,7 @@ func createManager(t *testing.T) (*DefaultAccountManager, error) {
return nil, err return nil, err
} }
eventStore := &activity.InMemoryEventStore{} 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) { func createStore(t *testing.T) (Store, error) {

View File

@ -104,6 +104,8 @@ const (
UserBlocked UserBlocked
// UserUnblocked indicates that a user unblocked another user // UserUnblocked indicates that a user unblocked another user
UserUnblocked UserUnblocked
// UserDeleted indicates that a user deleted another user
UserDeleted
// GroupDeleted indicates that a user deleted group // GroupDeleted indicates that a user deleted group
GroupDeleted GroupDeleted
// UserLoggedInPeer indicates that user logged in their peer with an interactive SSO login // 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"}, ServiceUserDeleted: {"Service user deleted", "service.user.delete"},
UserBlocked: {"User blocked", "user.block"}, UserBlocked: {"User blocked", "user.block"},
UserUnblocked: {"User unblocked", "user.unblock"}, UserUnblocked: {"User unblocked", "user.unblock"},
UserDeleted: {"User deleted", "user.delete"},
GroupDeleted: {"Group deleted", "group.delete"}, GroupDeleted: {"Group deleted", "group.delete"},
UserLoggedInPeer: {"User logged in peer", "user.peer.login"}, UserLoggedInPeer: {"User logged in peer", "user.peer.login"},
PeerLoginExpired: {"Peer login expired", "peer.login.expire"}, PeerLoginExpired: {"Peer login expired", "peer.login.expire"},

View File

@ -18,10 +18,13 @@ type Event struct {
ID uint64 ID uint64
// InitiatorID is the ID of an object that initiated the event (e.g., a user) // InitiatorID is the ID of an object that initiated the event (e.g., a user)
InitiatorID string 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 is the ID of an object that was effected by the event (e.g., a peer)
TargetID string TargetID string
// AccountID is the ID of an account where the event happened // AccountID is the ID of an account where the event happened
AccountID string AccountID string
// Meta of the event, e.g. deleted peer information like name, IP, etc // Meta of the event, e.g. deleted peer information like name, IP, etc
Meta map[string]any Meta map[string]any
} }
@ -35,12 +38,13 @@ func (e *Event) Copy() *Event {
} }
return &Event{ return &Event{
Timestamp: e.Timestamp, Timestamp: e.Timestamp,
Activity: e.Activity, Activity: e.Activity,
ID: e.ID, ID: e.ID,
InitiatorID: e.InitiatorID, InitiatorID: e.InitiatorID,
TargetID: e.TargetID, InitiatorEmail: e.InitiatorEmail,
AccountID: e.AccountID, TargetID: e.TargetID,
Meta: meta, AccountID: e.AccountID,
Meta: meta,
} }
} }

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -3,14 +3,14 @@ package sqlite
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt"
"github.com/netbirdio/netbird/management/server/activity"
// sqlite driver
"path/filepath" "path/filepath"
"time" "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 ( const (
@ -25,35 +25,62 @@ const (
"meta TEXT," + "meta TEXT," +
" target_id TEXT);" " target_id TEXT);"
selectDescQuery = "SELECT id, activity, timestamp, initiator_id, target_id, account_id, meta" + creatTableAccountEmailQuery = `CREATE TABLE IF NOT EXISTS deleted_users (id TEXT NOT NULL, email TEXT NOT NULL);`
" FROM events WHERE account_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?;"
selectAscQuery = "SELECT id, activity, timestamp, initiator_id, target_id, account_id, meta" + 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 WHERE account_id = ? ORDER BY timestamp ASC LIMIT ? OFFSET ?;" 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) " + insertQuery = "INSERT INTO events(activity, timestamp, initiator_id, target_id, account_id, meta) " +
"VALUES(?, ?, ?, ?, ?, ?)" "VALUES(?, ?, ?, ?, ?, ?)"
insertDeleteUserQuery = `INSERT INTO deleted_users(id, email) VALUES(?, ?)`
) )
// Store is the implementation of the activity.Store interface backed by SQLite // Store is the implementation of the activity.Store interface backed by SQLite
type Store struct { type Store struct {
db *sql.DB db *sql.DB
emailEncrypt *EmailEncrypt
insertStatement *sql.Stmt insertStatement *sql.Stmt
selectAscStatement *sql.Stmt selectAscStatement *sql.Stmt
selectDescStatement *sql.Stmt selectDescStatement *sql.Stmt
deleteUserStmt *sql.Stmt
} }
// NewSQLiteStore creates a new Store with an event table if not exists. // 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) dbFile := filepath.Join(dataDir, eventSinkDB)
db, err := sql.Open("sqlite3", dbFile) db, err := sql.Open("sqlite3", dbFile)
if err != nil { if err != nil {
return nil, err return nil, err
} }
crypt, err := NewEmailEncrypt(encryptionKey)
if err != nil {
return nil, err
}
_, err = db.Exec(createTableQuery) _, err = db.Exec(createTableQuery)
if err != nil { if err != nil {
return nil, err return nil, err
} }
_, err = db.Exec(creatTableAccountEmailQuery)
if err != nil {
return nil, err
}
insertStmt, err := db.Prepare(insertQuery) insertStmt, err := db.Prepare(insertQuery)
if err != nil { if err != nil {
return nil, err return nil, err
@ -69,25 +96,35 @@ func NewSQLiteStore(dataDir string) (*Store, error) {
return nil, err return nil, err
} }
return &Store{ deleteUserStmt, err := db.Prepare(insertDeleteUserQuery)
if err != nil {
return nil, err
}
s := &Store{
db: db, db: db,
emailEncrypt: crypt,
insertStatement: insertStmt, insertStatement: insertStmt,
selectDescStatement: selectDescStmt, selectDescStatement: selectDescStmt,
selectAscStatement: selectAscStmt, 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) events := make([]*activity.Event, 0)
for result.Next() { for result.Next() {
var id int64 var id int64
var operation activity.Activity var operation activity.Activity
var timestamp time.Time var timestamp time.Time
var initiator string var initiator string
var initiatorEmail *string
var target string var target string
var targetEmail *string
var account string var account string
var jsonMeta string var jsonMeta string
err := result.Scan(&id, &operation, &timestamp, &initiator, &target, &account, &jsonMeta) err := result.Scan(&id, &operation, &timestamp, &initiator, &initiatorEmail, &target, &targetEmail, &account, &jsonMeta)
if err != nil { if err != nil {
return nil, err 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, Timestamp: timestamp,
Activity: operation, Activity: operation,
ID: uint64(id), ID: uint64(id),
@ -108,7 +155,18 @@ func processResult(result *sql.Rows) ([]*activity.Event, error) {
TargetID: target, TargetID: target,
AccountID: account, AccountID: account,
Meta: meta, 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 return events, nil
@ -127,13 +185,18 @@ func (store *Store) Get(accountID string, offset, limit int, descending bool) ([
} }
defer result.Close() //nolint 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) { func (store *Store) Save(event *activity.Event) (*activity.Event, error) {
var jsonMeta string 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) metaBytes, err := json.Marshal(event.Meta)
if err != nil { if err != nil {
return nil, err return nil, err
@ -156,6 +219,29 @@ func (store *Store) Save(event *activity.Event) (*activity.Event, error) {
return eventCopy, nil 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 // Close the Store
func (store *Store) Close() error { func (store *Store) Close() error {
if store.db != nil { if store.db != nil {

View File

@ -12,7 +12,8 @@ import (
func TestNewSQLiteStore(t *testing.T) { func TestNewSQLiteStore(t *testing.T) {
dataDir := t.TempDir() dataDir := t.TempDir()
store, err := NewSQLiteStore(dataDir) key, _ := GenerateKey()
store, err := NewSQLiteStore(dataDir, key)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
return return

View File

@ -35,7 +35,8 @@ type Config struct {
TURNConfig *TURNConfig TURNConfig *TURNConfig
Signal *Host Signal *Host
Datadir string Datadir string
DataStoreEncryptionKey string
HttpConfig *HttpServerConfig HttpConfig *HttpServerConfig

View File

@ -191,7 +191,7 @@ func createDNSManager(t *testing.T) (*DefaultAccountManager, error) {
return nil, err return nil, err
} }
eventStore := &activity.InMemoryEventStore{} 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) { func createDNSStore(t *testing.T) (Store, error) {

0
management/server/http/api/generate.sh Normal file → Executable file
View File

View File

@ -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. description: The ID of the initiator of the event. E.g., an ID of a user that triggered the event.
type: string type: string
example: google-oauth2|123456789012345678901 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: target_id:
description: The ID of the target of the event. E.g., an ID of the peer that a user removed. description: The ID of the target of the event. E.g., an ID of the peer that a user removed.
type: string type: string
@ -938,6 +942,7 @@ components:
- activity - activity
- activity_code - activity_code
- initiator_id - initiator_id
- initiator_email
- target_id - target_id
- meta - meta
responses: responses:

View File

@ -164,6 +164,9 @@ type Event struct {
// Id Event unique identifier // Id Event unique identifier
Id string `json:"id"` 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 The ID of the initiator of the event. E.g., an ID of a user that triggered the event.
InitiatorId string `json:"initiator_id"` InitiatorId string `json:"initiator_id"`

View File

@ -45,14 +45,46 @@ func (h *EventsHandler) GetAllEvents(w http.ResponseWriter, r *http.Request) {
util.WriteError(err, w) util.WriteError(err, w)
return return
} }
events := make([]*api.Event, 0) events := make([]*api.Event, len(accountEvents))
for _, e := range accountEvents { for i, e := range accountEvents {
events = append(events, toEventResponse(e)) events[i] = toEventResponse(e)
}
err = h.fillEventsWithInitiatorEmail(events, account.Id, user.Id)
if err != nil {
util.WriteError(err, w)
return
} }
util.WriteJSONObject(w, events) 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 { func toEventResponse(event *activity.Event) *api.Event {
meta := make(map[string]string) meta := make(map[string]string)
if event.Meta != nil { if event.Meta != nil {
@ -60,13 +92,15 @@ func toEventResponse(event *activity.Event) *api.Event {
meta[s] = fmt.Sprintf("%v", a) meta[s] = fmt.Sprintf("%v", a)
} }
} }
return &api.Event{ e := &api.Event{
Id: fmt.Sprint(event.ID), Id: fmt.Sprint(event.ID),
InitiatorId: event.InitiatorID, InitiatorId: event.InitiatorID,
Activity: event.Activity.Message(), InitiatorEmail: event.InitiatorEmail,
ActivityCode: api.EventActivityCode(event.Activity.StringCode()), Activity: event.Activity.Message(),
TargetId: event.TargetID, ActivityCode: api.EventActivityCode(event.Activity.StringCode()),
Timestamp: event.Timestamp, TargetId: event.TargetID,
Meta: meta, Timestamp: event.Timestamp,
Meta: meta,
} }
return e
} }

View File

@ -37,6 +37,9 @@ func initEventsTestData(account string, user *server.User, events ...*activity.E
}, },
}, user, nil }, user, nil
}, },
GetUsersFromAccountFunc: func(accountID, userID string) ([]*server.UserInfo, error) {
return make([]*server.UserInfo, 0), nil
},
}, },
claimsExtractor: jwtclaims.NewClaimsExtractor( claimsExtractor: jwtclaims.NewClaimsExtractor(
jwtclaims.WithFromRequestContext(func(r *http.Request) jwtclaims.AuthorizationClaims { jwtclaims.WithFromRequestContext(func(r *http.Request) jwtclaims.AuthorizationClaims {

View File

@ -513,7 +513,9 @@ func buildUserExportRequest() (string, error) {
return string(str), nil 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() jwtToken, err := am.credentials.Authenticate()
if err != nil { if err != nil {
return nil, err return nil, err
@ -521,17 +523,23 @@ func (am *Auth0Manager) createPostRequest(endpoint string, payloadStr string) (*
reqURL := am.authIssuer + endpoint reqURL := am.authIssuer + endpoint
payload := strings.NewReader(payloadStr) req, err := http.NewRequest(method, reqURL, body)
req, err := http.NewRequest("POST", reqURL, payload)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken) 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") req.Header.Add("content-type", "application/json")
return req, nil return req, nil
} }
// GetAllAccounts gets all registered accounts with corresponding user data. // GetAllAccounts gets all registered accounts with corresponding user data.
@ -737,6 +745,38 @@ func (am *Auth0Manager) InviteUserByID(userID string) error {
return nil 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. // checkExportJobStatus checks the status of the job created at CreateExportUsersJob.
// If the status is "completed", then return the downloadLink // If the status is "completed", then return the downloadLink
func (am *Auth0Manager) checkExportJobStatus(jobID string) (bool, string, error) { func (am *Auth0Manager) checkExportJobStatus(jobID string) (bool, string, error) {

View File

@ -12,9 +12,10 @@ import (
"time" "time"
"github.com/golang-jwt/jwt" "github.com/golang-jwt/jwt"
"github.com/netbirdio/netbird/management/server/telemetry"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"goauthentik.io/api/v3" "goauthentik.io/api/v3"
"github.com/netbirdio/netbird/management/server/telemetry"
) )
// AuthentikManager authentik manager client instance. // AuthentikManager authentik manager client instance.
@ -453,6 +454,38 @@ func (am *AuthentikManager) InviteUserByID(_ string) error {
return fmt.Errorf("method InviteUserByID not implemented") 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) { func (am *AuthentikManager) authenticationContext() (context.Context, error) {
jwtToken, err := am.credentials.Authenticate() jwtToken, err := am.credentials.Authenticate()
if err != nil { if err != nil {

View File

@ -454,6 +454,43 @@ func (am *AzureManager) InviteUserByID(_ string) error {
return fmt.Errorf("method InviteUserByID not implemented") 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) { func (am *AzureManager) getUserExtensions() ([]azureExtension, error) {
q := url.Values{} q := url.Values{}
q.Add("$select", extensionFields) q.Add("$select", extensionFields)

View File

@ -254,6 +254,19 @@ func (gm *GoogleWorkspaceManager) InviteUserByID(_ string) error {
return fmt.Errorf("method InviteUserByID not implemented") 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. // getGoogleCredentials retrieves Google credentials based on the provided serviceAccountKey.
// It decodes the base64-encoded serviceAccountKey and attempts to obtain credentials using it. // 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. // If that fails, it falls back to using the default Google credentials path.

View File

@ -18,6 +18,7 @@ type Manager interface {
CreateUser(email, name, accountID, invitedByEmail string) (*UserData, error) CreateUser(email, name, accountID, invitedByEmail string) (*UserData, error)
GetUserByEmail(email string) ([]*UserData, error) GetUserByEmail(email string) ([]*UserData, error)
InviteUserByID(userID string) error InviteUserByID(userID string) error
DeleteUser(userID string) error
} }
// ClientConfig defines common client configuration for all IdP manager // ClientConfig defines common client configuration for all IdP manager

View File

@ -467,6 +467,47 @@ func (km *KeycloakManager) InviteUserByID(_ string) error {
return fmt.Errorf("method InviteUserByID not implemented") 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) { func buildKeycloakCreateUserRequestPayload(email string, name string, appMetadata AppMetadata) (string, error) {
attrs := keycloakUserAttributes{} attrs := keycloakUserAttributes{}
attrs.Set(wtAccountID, appMetadata.WTAccountID) attrs.Set(wtAccountID, appMetadata.WTAccountID)

View File

@ -319,6 +319,28 @@ func (om *OktaManager) InviteUserByID(_ string) error {
return fmt.Errorf("method InviteUserByID not implemented") 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, // updateUserProfileSchema updates the Okta user schema to include custom fields,
// wt_account_id and wt_pending_invite. // wt_account_id and wt_pending_invite.
func updateUserProfileSchema(client *okta.Client) error { func updateUserProfileSchema(client *okta.Client) error {

View File

@ -428,7 +428,7 @@ func (zm *ZitadelManager) UpdateUserAppMetadata(userID string, appMetadata AppMe
return err return err
} }
resource := fmt.Sprintf("users/%s/metadata/_bulk", userID) resource := fmt.Sprintf("users/%s", userID)
_, err = zm.post(resource, string(payload)) _, err = zm.post(resource, string(payload))
if err != nil { if err != nil {
return err return err
@ -447,6 +447,21 @@ func (zm *ZitadelManager) InviteUserByID(_ string) error {
return fmt.Errorf("method InviteUserByID not implemented") 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. // getUserMetadata requests user metadata from zitadel via ID.
func (zm *ZitadelManager) getUserMetadata(userID string) ([]zitadelMetadata, error) { func (zm *ZitadelManager) getUserMetadata(userID string) ([]zitadelMetadata, error) {
resource := fmt.Sprintf("users/%s/metadata/_search", userID) 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) 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. // get perform Get requests.
func (zm *ZitadelManager) get(resource string, q url.Values) ([]byte, error) { func (zm *ZitadelManager) get(resource string, q url.Values) ([]byte, error) {
jwtToken, err := zm.credentials.Authenticate() jwtToken, err := zm.credentials.Authenticate()

View File

@ -412,7 +412,7 @@ func startManagement(t *testing.T, config *Config) (*grpc.Server, string, error)
peersUpdateManager := NewPeersUpdateManager() peersUpdateManager := NewPeersUpdateManager()
eventStore := &activity.InMemoryEventStore{} eventStore := &activity.InMemoryEventStore{}
accountManager, err := BuildManager(store, peersUpdateManager, nil, "", "", accountManager, err := BuildManager(store, peersUpdateManager, nil, "", "",
eventStore) eventStore, false)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }

View File

@ -503,7 +503,7 @@ func startServer(config *server.Config) (*grpc.Server, net.Listener) {
peersUpdateManager := server.NewPeersUpdateManager() peersUpdateManager := server.NewPeersUpdateManager()
eventStore := &activity.InMemoryEventStore{} eventStore := &activity.InMemoryEventStore{}
accountManager, err := server.BuildManager(store, peersUpdateManager, nil, "", "", accountManager, err := server.BuildManager(store, peersUpdateManager, nil, "", "",
eventStore) eventStore, false)
if err != nil { if err != nil {
log.Fatalf("failed creating a manager: %v", err) log.Fatalf("failed creating a manager: %v", err)
} }

View File

@ -744,7 +744,7 @@ func createNSManager(t *testing.T) (*DefaultAccountManager, error) {
return nil, err return nil, err
} }
eventStore := &activity.InMemoryEventStore{} eventStore := &activity.InMemoryEventStore{}
return BuildManager(store, NewPeersUpdateManager(), nil, "", "", eventStore) return BuildManager(store, NewPeersUpdateManager(), nil, "", "", eventStore, false)
} }
func createNSStore(t *testing.T) (Store, error) { func createNSStore(t *testing.T) (Store, error) {

View File

@ -681,7 +681,7 @@ func createRouterManager(t *testing.T) (*DefaultAccountManager, error) {
return nil, err return nil, err
} }
eventStore := &activity.InMemoryEventStore{} eventStore := &activity.InMemoryEventStore{}
return BuildManager(store, NewPeersUpdateManager(), nil, "", "", eventStore) return BuildManager(store, NewPeersUpdateManager(), nil, "", "", eventStore, false)
} }
func createRouterStore(t *testing.T) (Store, error) { func createRouterStore(t *testing.T) (Store, error) {

View File

@ -2,6 +2,7 @@ package telemetry
import ( import (
"context" "context"
"go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/metric/instrument" "go.opentelemetry.io/otel/metric/instrument"
"go.opentelemetry.io/otel/metric/instrument/syncint64" "go.opentelemetry.io/otel/metric/instrument/syncint64"
@ -13,6 +14,7 @@ type IDPMetrics struct {
getUserByEmailCounter syncint64.Counter getUserByEmailCounter syncint64.Counter
getAllAccountsCounter syncint64.Counter getAllAccountsCounter syncint64.Counter
createUserCounter syncint64.Counter createUserCounter syncint64.Counter
deleteUserCounter syncint64.Counter
getAccountCounter syncint64.Counter getAccountCounter syncint64.Counter
getUserByIDCounter syncint64.Counter getUserByIDCounter syncint64.Counter
authenticateRequestCounter syncint64.Counter authenticateRequestCounter syncint64.Counter
@ -39,6 +41,10 @@ func NewIDPMetrics(ctx context.Context, meter metric.Meter) (*IDPMetrics, error)
if err != nil { if err != nil {
return nil, err 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")) getAccountCounter, err := meter.SyncInt64().Counter("management.idp.get.account.counter", instrument.WithUnit("1"))
if err != nil { if err != nil {
return nil, err return nil, err
@ -65,6 +71,7 @@ func NewIDPMetrics(ctx context.Context, meter metric.Meter) (*IDPMetrics, error)
getUserByEmailCounter: getUserByEmailCounter, getUserByEmailCounter: getUserByEmailCounter,
getAllAccountsCounter: getAllAccountsCounter, getAllAccountsCounter: getAllAccountsCounter,
createUserCounter: createUserCounter, createUserCounter: createUserCounter,
deleteUserCounter: deleteUserCounter,
getAccountCounter: getAccountCounter, getAccountCounter: getAccountCounter,
getUserByIDCounter: getUserByIDCounter, getUserByIDCounter: getUserByIDCounter,
authenticateRequestCounter: authenticateRequestCounter, authenticateRequestCounter: authenticateRequestCounter,
@ -88,6 +95,11 @@ func (idpMetrics *IDPMetrics) CountCreateUser() {
idpMetrics.createUserCounter.Add(idpMetrics.ctx, 1) idpMetrics.createUserCounter.Add(idpMetrics.ctx, 1)
} }
// CountDeleteUser ...
func (idpMetrics *IDPMetrics) CountDeleteUser() {
idpMetrics.deleteUserCounter.Add(idpMetrics.ctx, 1)
}
// CountGetAllAccounts ... // CountGetAllAccounts ...
func (idpMetrics *IDPMetrics) CountGetAllAccounts() { func (idpMetrics *IDPMetrics) CountGetAllAccounts() {
idpMetrics.getAllAccountsCounter.Add(idpMetrics.ctx, 1) idpMetrics.getAllAccountsCounter.Add(idpMetrics.ctx, 1)

View File

@ -327,15 +327,43 @@ func (am *DefaultAccountManager) DeleteUser(accountID, initiatorUserID string, t
return status.Errorf(status.NotFound, "user not found") return status.Errorf(status.NotFound, "user not found")
} }
if executingUser.Role != UserRoleAdmin { 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 { peers, err := account.FindUserPeers(targetUserID)
return status.Errorf(status.PermissionDenied, "regular users can not be deleted") if err != nil {
return status.Errorf(status.Internal, "failed to find user peers")
} }
meta := map[string]any{"name": targetUser.ServiceUserName} if err := am.expireAndUpdatePeers(account, peers); err != nil {
am.storeEvent(initiatorUserID, targetUserID, accountID, activity.ServiceUserDeleted, meta) 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) delete(account.Users, targetUserID)
@ -609,23 +637,10 @@ func (am *DefaultAccountManager) SaveUser(accountID, initiatorUserID string, upd
if err != nil { if err != nil {
return nil, err 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 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) { func findUserInIDPUserdata(userID string, userData []*idp.UserData) (*idp.UserData, bool) {
for _, user := range userData { for _, user := range userData {
if user.ID == userID { if user.ID == userID {

View File

@ -439,8 +439,9 @@ func TestUser_DeleteUser_regularUser(t *testing.T) {
} }
err = am.DeleteUser(mockAccountID, mockUserID, mockUserID) err = am.DeleteUser(mockAccountID, mockUserID, mockUserID)
if err != nil {
assert.Errorf(t, err, "Regular users can not be deleted (yet)") t.Errorf("unexpected error: %s", err)
}
} }
func TestDefaultAccountManager_GetUser(t *testing.T) { func TestDefaultAccountManager_GetUser(t *testing.T) {