Repository: netbirdio/netbird

Issue:
Google Workspace IdP not setting email addresses properly
**Describe the problem**
With a clean install of v0.22.7 and leveraging Google Workspace as the IdP, users are only showing ID's with no email attribute set. Based on #959, `NETBIRD_MGMT_IDP="none"` has been defined in setup.env to fix the Invalid JWT issue. I'm assuming the lack of email addresses is a side effect.

**To Reproduce**
Steps to reproduce the behavior:

1. Pull latest https://github.com/netbirdio/netbird.git
2. Copy infrastructure_files/setup.env.template to infrastructure_files/setup.env (overwrite existing file from old version)
3. Fill in correct values as identified in the Google Workspace setup guide: https://docs.netbird.io/selfhosted/identity-providers#google-workspace
4. Re-run ./configure.sh
5. Run docker compose pull && docker compose down && docker compose up -d

**Expected behavior**
Email addresses are set in Netbird when a user entry is created.

**Screenshots**
![users](https://github.com/netbirdio/netbird/assets/104649/6fb0e4c1-f7ef-4523-b2f0-b077f3835c78)
![user-details](https://github.com/netbirdio/netbird/assets/104649/92d097c4-b3c5-4cfe-8fed-d3e4fad0ee08)


**Additional context**
Setting `NETBIRD_MGMT_IDP="google"` caused an Invalid JWT error and rendered Netbird unusable.


Discussion:
@mosi0815: I just found the problem that makes the Google IDP unusable. On the first login with a new user, Netbird tries to set the wt_account_id attribute in the users app_metadata custom schema. 
This fails because of an invalid metadata payload in the update query. It contains an additional field that Netbird didn't add to the custom schema beforehand.
I'm neither a GO expert nor very familiar with the Netbird codebase but I got it working by changing the AppMetadata struct in idp.go like this:
```go
type AppMetadata struct {
        // WTAccountID is a NetBird (previously Wiretrustee) account id to update in the IDP
        // maps to wt_account_id when json.marshal
        WTAccountID     string `json:"wt_account_id,omitempty"`
        WTPendingInvite *bool  `json:"wt_pending_invite,omitempty"`
        WTInvitedBy     string `json:"wt_invited_by_email,omitempty"`
}
```

Google needs a couple of seconds to update the metadata. So the invalid JWT error still appears after the first login but after a refresh of the page a couple of seconds later everything works as expected. @mosi0815: Update:
I found a workaround without modifying the code.
Adding a custom text field wt_invited_by_email to the app_metadata custom attribute in the admin console also fixes the problem.  @braginini: > Update: I found a workaround without modifying the code. Adding a custom text field wt_invited_by_email to the app_metadata custom attribute in the admin console also fixes the problem.

Hey @mosi0815 
Did you it in the Google IDP admin console?

We could also create a PR modifying the AppMetadata to omit empty values as you did in the code.

https://github.com/netbirdio/netbird/pull/1122@moserpjm: I manually added it via the Google Workspace Admin Console:
![image](https://github.com/netbirdio/netbird/assets/137904805/8cddd7fa-8f4b-4141-b260-5b114a342a5f)

I think the root cause of this bug is that the Google Workspace integration doesn't add all the required fields. @nazarewk: I am closing without much digging into it, because https://github.com/netbirdio/netbird/pull/1147 probably fixed the issue.@xrehpicx: Hosted latest version as of rn and still getting this issue @moserpjm: Don't stress yourself with the Google integration. The whole custom field solution is less than ideal. Install Zitadel or Keycloak and configure Google there. If you want to control access globally you can do it there. In case you want to use your Google Groups its gets a litte bit more difficult. Google is not sending a group claim. If you Google this you will find feature requests from 15 Years ago. They dont watn to do it because its a completely different API which is rather slow. 
For Keycloak theres a plugin. Its rather old but works. Uses the Workspace Directory API. So you have to delegate an actual Admin to the service user. It doesnt resolve nested groups btw. Theres no API Method for it and they didn't implement it theirself. 
I case of Zitadel its a littel bit more tricky. You can use an Action to Complement your token. It only supports plain Javascript Actions. So calling the new CloudIdentity API to retrieve all groups of a user recursivly is possible but you would have to do it 100% manually. Oauth everything.
So we ended up with a solution lke that.

![Image](https://github.com/user-attachments/assets/8d489f7c-ea2c-4036-b063-6e46c528ce13)

Thats your IDE BTW :D:

![Image](https://github.com/user-attachments/assets/4d1ab762-174e-4d3c-98ed-4b8450c760cf)

A Spring Boot App with the whole stack of Google API libs gets the email of the user and returns a JSON array with all groups which get added as claim by the JS. We also added a little bit of cache because those API calls can take 5 seconds or more. Sounds complicated but its actually 30 lines of code. Calling HTTP  endpoints from actions is only possible on localhost BTW. All other host require a valid SSL certificate. So no calls to anoter Container or a host in your local network without certificate mayhem. But hey if it works you finally get OAuth groups for all your apps not only Netbird. Like EntraID in the free edition :D

Solution patch:
diff --git a/client/android/login.go b/client/android/login.go
index 8d2636c9..afd61055 100644
--- a/client/android/login.go
+++ b/client/android/login.go
@@ -193,7 +193,7 @@ func (a *Auth) login(urlOpener URLOpener) error {
 }
 
 func (a *Auth) foregroundGetTokenInfo(urlOpener URLOpener) (*auth.TokenInfo, error) {
-	oAuthFlow, err := auth.NewOAuthFlow(a.ctx, a.config)
+	oAuthFlow, err := auth.NewOAuthFlow(a.ctx, a.config, false)
 	if err != nil {
 		return nil, err
 	}
diff --git a/client/cmd/login.go b/client/cmd/login.go
index 5433db52..2ddab46f 100644
--- a/client/cmd/login.go
+++ b/client/cmd/login.go
@@ -3,6 +3,7 @@ package cmd
 import (
 	"context"
 	"fmt"
+	"os"
 	"strings"
 	"time"
 
@@ -80,9 +81,10 @@ var loginCmd = &cobra.Command{
 		client := proto.NewDaemonServiceClient(conn)
 
 		loginRequest := proto.LoginRequest{
-			SetupKey:      setupKey,
-			PreSharedKey:  preSharedKey,
-			ManagementUrl: managementURL,
+			SetupKey:             setupKey,
+			PreSharedKey:         preSharedKey,
+			ManagementUrl:        managementURL,
+			IsLinuxDesktopClient: isLinuxRunningDesktop(),
 		}
 
 		var loginErr error
@@ -163,7 +165,7 @@ func foregroundLogin(ctx context.Context, cmd *cobra.Command, config *internal.C
 }
 
 func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *internal.Config) (*auth.TokenInfo, error) {
-	oAuthFlow, err := auth.NewOAuthFlow(ctx, config)
+	oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isLinuxRunningDesktop())
 	if err != nil {
 		return nil, err
 	}
@@ -202,3 +204,8 @@ func openURL(cmd *cobra.Command, verificationURIComplete, userCode string) {
 			"https://docs.netbird.io/how-to/register-machines-using-setup-keys")
 	}
 }
+
+// isLinuxRunningDesktop checks if a Linux OS is running desktop environment
+func isLinuxRunningDesktop() bool {
+	return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != ""
+}
diff --git a/client/cmd/up.go b/client/cmd/up.go
index a275e88d..8d682c46 100644
--- a/client/cmd/up.go
+++ b/client/cmd/up.go
@@ -141,13 +141,14 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command) error {
 	}
 
 	loginRequest := proto.LoginRequest{
-		SetupKey:            setupKey,
-		PreSharedKey:        preSharedKey,
-		ManagementUrl:       managementURL,
-		AdminURL:            adminURL,
-		NatExternalIPs:      natExternalIPs,
-		CleanNATExternalIPs: natExternalIPs != nil && len(natExternalIPs) == 0,
-		CustomDNSAddress:    customDNSAddressConverted,
+		SetupKey:             setupKey,
+		PreSharedKey:         preSharedKey,
+		ManagementUrl:        managementURL,
+		AdminURL:             adminURL,
+		NatExternalIPs:       natExternalIPs,
+		CleanNATExternalIPs:  natExternalIPs != nil && len(natExternalIPs) == 0,
+		CustomDNSAddress:     customDNSAddressConverted,
+		IsLinuxDesktopClient: isLinuxRunningDesktop(),
 	}
 
 	var loginErr error
diff --git a/client/firewall/iptables/manager_linux.go b/client/firewall/iptables/manager_linux.go
index 753282d8..048c0fd5 100644
--- a/client/firewall/iptables/manager_linux.go
+++ b/client/firewall/iptables/manager_linux.go
@@ -93,7 +93,7 @@ func Create(wgIface iFaceMapper, ipv6Supported bool) (*Manager, error) {
 
 // AddFiltering rule to the firewall
 //
-// If comment is empty rule ID is used as comment
+// Comment will be ignored because some system this feature is not supported
 func (m *Manager) AddFiltering(
 	ip net.IP,
 	protocol fw.Protocol,
@@ -123,9 +123,6 @@ func (m *Manager) AddFiltering(
 	ipsetName = m.transformIPsetName(ipsetName, sPortVal, dPortVal)
 
 	ruleID := uuid.New().String()
-	if comment == "" {
-		comment = ruleID
-	}
 
 	if ipsetName != "" {
 		rs, rsExists := m.rulesets[ipsetName]
@@ -157,8 +154,7 @@ func (m *Manager) AddFiltering(
 		// this is new ipset so we need to create firewall rule for it
 	}
 
-	specs := m.filterRuleSpecs("filter", ip, string(protocol), sPortVal, dPortVal,
-		direction, action, comment, ipsetName)
+	specs := m.filterRuleSpecs(ip, string(protocol), sPortVal, dPortVal, direction, action, ipsetName)
 
 	if direction == fw.RuleDirectionOUT {
 		ok, err := client.Exists("filter", ChainOutputFilterName, specs...)
@@ -283,7 +279,7 @@ func (m *Manager) AllowNetbird() error {
 			fw.RuleDirectionIN,
 			fw.ActionAccept,
 			"",
-			"allow netbird interface traffic",
+			"",
 		)
 		if err != nil {
 			return fmt.Errorf("failed to allow netbird interface traffic: %w", err)
@@ -296,7 +292,7 @@ func (m *Manager) AllowNetbird() error {
 			fw.RuleDirectionOUT,
 			fw.ActionAccept,
 			"",
-			"allow netbird interface traffic",
+			"",
 		)
 		return err
 	}
@@ -362,9 +358,7 @@ func (m *Manager) reset(client *iptables.IPTables, table string) error {
 
 // filterRuleSpecs returns the specs of a filtering rule
 func (m *Manager) filterRuleSpecs(
-	table string, ip net.IP, protocol string, sPort, dPort string,
-	direction fw.RuleDirection, action fw.Action, comment string,
-	ipsetName string,
+	ip net.IP, protocol string, sPort, dPort string, direction fw.RuleDirection, action fw.Action, ipsetName string,
 ) (specs []string) {
 	matchByIP := true
 	// don't use IP matching if IP is ip 0.0.0.0
@@ -398,8 +392,7 @@ func (m *Manager) filterRuleSpecs(
 	if dPort != "" {
 		specs = append(specs, "--dport", dPort)
 	}
-	specs = append(specs, "-j", m.actionToStr(action))
-	return append(specs, "-m", "comment", "--comment", comment)
+	return append(specs, "-j", m.actionToStr(action))
 }
 
 // rawClient returns corresponding iptables client for the given ip
diff --git a/client/internal/auth/oauth.go b/client/internal/auth/oauth.go
index 8731e4f0..82adf91b 100644
--- a/client/internal/auth/oauth.go
+++ b/client/internal/auth/oauth.go
@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"runtime"
 
+	log "github.com/sirupsen/logrus"
 	"google.golang.org/grpc/codes"
 	gstatus "google.golang.org/grpc/status"
 
@@ -63,14 +64,16 @@ func (t TokenInfo) GetTokenToUse() string {
 // and if that also fails, the authentication process is deemed unsuccessful
 //
 // On Linux distros without desktop environment support, it only tries to initialize the Device Code Flow
-func NewOAuthFlow(ctx context.Context, config *internal.Config) (OAuthFlow, error) {
-	if runtime.GOOS == "linux" && !isLinuxRunningDesktop() {
+func NewOAuthFlow(ctx context.Context, config *internal.Config, isLinuxDesktopClient bool) (OAuthFlow, error) {
+	if runtime.GOOS == "linux" && !isLinuxDesktopClient {
 		return authenticateWithDeviceCodeFlow(ctx, config)
 	}
 
 	pkceFlow, err := authenticateWithPKCEFlow(ctx, config)
 	if err != nil {
 		// fallback to device code flow
+		log.Debugf("failed to initialize pkce authentication with error: %v\n", err)
+		log.Debug("falling back to device code flow")
 		return authenticateWithDeviceCodeFlow(ctx, config)
 	}
 	return pkceFlow, nil
diff --git a/client/internal/auth/util.go b/client/internal/auth/util.go
index e61e0f17..31c81d70 100644
--- a/client/internal/auth/util.go
+++ b/client/internal/auth/util.go
@@ -7,8 +7,6 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
-	"os"
-	"reflect"
 	"strings"
 )
 
@@ -45,15 +43,14 @@ func isValidAccessToken(token string, audience string) error {
 	}
 
 	// Audience claim of JWT can be a string or an array of strings
-	typ := reflect.TypeOf(claims.Audience)
-	switch typ.Kind() {
-	case reflect.String:
-		if claims.Audience == audience {
+	switch aud := claims.Audience.(type) {
+	case string:
+		if aud == audience {
 			return nil
 		}
-	case reflect.Slice:
-		for _, aud := range claims.Audience.([]interface{}) {
-			if audience == aud {
+	case []interface{}:
+		for _, audItem := range aud {
+			if audStr, ok := audItem.(string); ok && audStr == audience {
 				return nil
 			}
 		}
@@ -61,8 +58,3 @@ func isValidAccessToken(token string, audience string) error {
 
 	return fmt.Errorf("invalid JWT token audience field")
 }
-
-// isLinuxRunningDesktop checks if a Linux OS is running desktop environment
-func isLinuxRunningDesktop() bool {
-	return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != ""
-}
diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go
index 13b55fc1..4dc98942 100644
--- a/client/proto/daemon.pb.go
+++ b/client/proto/daemon.pb.go
@@ -1,7 +1,7 @@
 // Code generated by protoc-gen-go. DO NOT EDIT.
 // versions:
 // 	protoc-gen-go v1.26.0
-// 	protoc        v3.21.9
+// 	protoc        v4.23.4
 // source: daemon.proto
 
 package proto
@@ -40,8 +40,9 @@ type LoginRequest struct {
 	// cleanNATExternalIPs clean map list of external IPs.
 	// This is needed because the generated code
 	// omits initialized empty slices due to omitempty tags
-	CleanNATExternalIPs bool   `protobuf:"varint,6,opt,name=cleanNATExternalIPs,proto3" json:"cleanNATExternalIPs,omitempty"`
-	CustomDNSAddress    []byte `protobuf:"bytes,7,opt,name=customDNSAddress,proto3" json:"customDNSAddress,omitempty"`
+	CleanNATExternalIPs  bool   `protobuf:"varint,6,opt,name=cleanNATExternalIPs,proto3" json:"cleanNATExternalIPs,omitempty"`
+	CustomDNSAddress     []byte `protobuf:"bytes,7,opt,name=customDNSAddress,proto3" json:"customDNSAddress,omitempty"`
+	IsLinuxDesktopClient bool   `protobuf:"varint,8,opt,name=isLinuxDesktopClient,proto3" json:"isLinuxDesktopClient,omitempty"`
 }
 
 func (x *LoginRequest) Reset() {
@@ -125,6 +126,13 @@ func (x *LoginRequest) GetCustomDNSAddress() []byte {
 	return nil
 }
 
+func (x *LoginRequest) GetIsLinuxDesktopClient() bool {
+	if x != nil {
+		return x.IsLinuxDesktopClient
+	}
+	return false
+}
+
 type LoginResponse struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
@@ -1043,7 +1051,7 @@ var file_daemon_proto_rawDesc = []byte{
 	0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74,
 	0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
 	0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74,
-	0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x96, 0x02, 0x0a, 0x0c, 0x4c, 0x6f,
+	0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xca, 0x02, 0x0a, 0x0c, 0x4c, 0x6f,
 	0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x65,
 	0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x65,
 	0x74, 0x75, 0x70, 0x4b, 0x65, 0x79, 0x12, 0x22, 0x0a, 0x0c, 0x70, 0x72, 0x65, 0x53, 0x68, 0x61,
@@ -1061,128 +1069,131 @@ var file_daemon_proto_rawDesc = []byte{
 	0x6e, 0x61, 0x6c, 0x49, 0x50, 0x73, 0x12, 0x2a, 0x0a, 0x10, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d,
 	0x44, 0x4e, 0x53, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c,
 	0x52, 0x10, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x44, 0x4e, 0x53, 0x41, 0x64, 0x64, 0x72, 0x65,
-	0x73, 0x73, 0x22, 0xb5, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70,
-	0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x6e, 0x65, 0x65, 0x64, 0x73, 0x53, 0x53, 0x4f,
-	0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x6e, 0x65, 0x65,
-	0x64, 0x73, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73,
-	0x65, 0x72, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73,
-	0x65, 0x72, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69,
-	0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55, 0x52, 0x49, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
-	0x0f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55, 0x52, 0x49,
-	0x12, 0x38, 0x0a, 0x17, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
-	0x55, 0x52, 0x49, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28,
-	0x09, 0x52, 0x17, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55,
-	0x52, 0x49, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0x31, 0x0a, 0x13, 0x57, 0x61,
-	0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
-	0x74, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20,
-	0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x64, 0x65, 0x22, 0x16, 0x0a,
-	0x14, 0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73,
-	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x0b, 0x0a, 0x09, 0x55, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65,
-	0x73, 0x74, 0x22, 0x0c, 0x0a, 0x0a, 0x55, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
-	0x22, 0x3d, 0x0a, 0x0d, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
-	0x74, 0x12, 0x2c, 0x0a, 0x11, 0x67, 0x65, 0x74, 0x46, 0x75, 0x6c, 0x6c, 0x50, 0x65, 0x65, 0x72,
-	0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x67, 0x65,
-	0x74, 0x46, 0x75, 0x6c, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22,
-	0x82, 0x01, 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
-	0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01,
-	0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x32, 0x0a, 0x0a, 0x66, 0x75,
-	0x6c, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12,
-	0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x46, 0x75, 0x6c, 0x6c, 0x53, 0x74, 0x61, 0x74,
-	0x75, 0x73, 0x52, 0x0a, 0x66, 0x75, 0x6c, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x24,
-	0x0a, 0x0d, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18,
-	0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x56, 0x65, 0x72,
-	0x73, 0x69, 0x6f, 0x6e, 0x22, 0x0d, 0x0a, 0x0b, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x71, 0x75,
-	0x65, 0x73, 0x74, 0x22, 0x0e, 0x0a, 0x0c, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
-	0x6e, 0x73, 0x65, 0x22, 0x12, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
-	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xb3, 0x01, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x43,
-	0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a,
-	0x0d, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x55, 0x72, 0x6c, 0x18, 0x01,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
-	0x55, 0x72, 0x6c, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46, 0x69, 0x6c,
-	0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x46,
-	0x69, 0x6c, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6c, 0x6f, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x18, 0x03,
-	0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6c, 0x6f, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x22, 0x0a,
-	0x0c, 0x70, 0x72, 0x65, 0x53, 0x68, 0x61, 0x72, 0x65, 0x64, 0x4b, 0x65, 0x79, 0x18, 0x04, 0x20,
-	0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x72, 0x65, 0x53, 0x68, 0x61, 0x72, 0x65, 0x64, 0x4b, 0x65,
-	0x79, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x55, 0x52, 0x4c, 0x18, 0x05, 0x20,
-	0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x55, 0x52, 0x4c, 0x22, 0xcf, 0x02,
-	0x0a, 0x09, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49,
-	0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06, 0x70,
-	0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x75, 0x62,
-	0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75,
-	0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e, 0x6e, 0x53, 0x74, 0x61,
-	0x74, 0x75, 0x73, 0x12, 0x46, 0x0a, 0x10, 0x63, 0x6f, 0x6e, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75,
-	0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
-	0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
-	0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x63, 0x6f, 0x6e, 0x6e, 0x53,
-	0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x72,
-	0x65, 0x6c, 0x61, 0x79, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x72, 0x65,
-	0x6c, 0x61, 0x79, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x18,
-	0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x12, 0x34, 0x0a,
-	0x15, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x49, 0x63, 0x65, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61,
-	0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x15, 0x6c, 0x6f,
-	0x63, 0x61, 0x6c, 0x49, 0x63, 0x65, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54,
-	0x79, 0x70, 0x65, 0x12, 0x36, 0x0a, 0x16, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x63, 0x65,
-	0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x18, 0x08, 0x20,
-	0x01, 0x28, 0x09, 0x52, 0x16, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49, 0x63, 0x65, 0x43, 0x61,
-	0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x66,
-	0x71, 0x64, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x22,
-	0x76, 0x0a, 0x0e, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74,
-	0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49,
-	0x50, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28,
-	0x09, 0x52, 0x06, 0x70, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a, 0x0f, 0x6b, 0x65, 0x72,
-	0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01,
-	0x28, 0x08, 0x52, 0x0f, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x66,
-	0x61, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28,
-	0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x22, 0x3d, 0x0a, 0x0b, 0x53, 0x69, 0x67, 0x6e, 0x61,
-	0x6c, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x55, 0x52, 0x4c, 0x18, 0x01, 0x20,
-	0x01, 0x28, 0x09, 0x52, 0x03, 0x55, 0x52, 0x4c, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x6e,
-	0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x63, 0x6f, 0x6e,
-	0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x22, 0x41, 0x0a, 0x0f, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65,
-	0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x55, 0x52, 0x4c,
-	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x55, 0x52, 0x4c, 0x12, 0x1c, 0x0a, 0x09, 0x63,
-	0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09,
-	0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x22, 0xef, 0x01, 0x0a, 0x0a, 0x46, 0x75,
-	0x6c, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x41, 0x0a, 0x0f, 0x6d, 0x61, 0x6e, 0x61,
-	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
-	0x0b, 0x32, 0x17, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4d, 0x61, 0x6e, 0x61, 0x67,
-	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x0f, 0x6d, 0x61, 0x6e, 0x61,
-	0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x35, 0x0a, 0x0b, 0x73,
-	0x69, 0x67, 0x6e, 0x61, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
-	0x32, 0x13, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x6c,
-	0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x0b, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x53, 0x74, 0x61,
-	0x74, 0x65, 0x12, 0x3e, 0x0a, 0x0e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53,
-	0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x64, 0x61, 0x65,
-	0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61,
-	0x74, 0x65, 0x52, 0x0e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61,
-	0x74, 0x65, 0x12, 0x27, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28,
-	0x0b, 0x32, 0x11, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x53,
-	0x74, 0x61, 0x74, 0x65, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x32, 0xf7, 0x02, 0x0a, 0x0d,
-	0x44, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x36, 0x0a,
-	0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x14, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e,
-	0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x64,
-	0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
-	0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0c, 0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f,
-	0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x57,
-	0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65,
-	0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x57, 0x61, 0x69, 0x74,
-	0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
-	0x22, 0x00, 0x12, 0x2d, 0x0a, 0x02, 0x55, 0x70, 0x12, 0x11, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f,
-	0x6e, 0x2e, 0x55, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x64, 0x61,
-	0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x55, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
-	0x00, 0x12, 0x39, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x15, 0x2e, 0x64, 0x61,
-	0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
-	0x73, 0x74, 0x1a, 0x16, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x74, 0x61, 0x74,
-	0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x04,
-	0x44, 0x6f, 0x77, 0x6e, 0x12, 0x13, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x6f,
-	0x77, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x64, 0x61, 0x65, 0x6d,
-	0x6f, 0x6e, 0x2e, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
-	0x00, 0x12, 0x42, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x18,
-	0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
-	0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f,
-	0x6e, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f,
-	0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
-	0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+	0x73, 0x73, 0x12, 0x32, 0x0a, 0x14, 0x69, 0x73, 0x4c, 0x69, 0x6e, 0x75, 0x78, 0x44, 0x65, 0x73,
+	0x6b, 0x74, 0x6f, 0x70, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08,
+	0x52, 0x14, 0x69, 0x73, 0x4c, 0x69, 0x6e, 0x75, 0x78, 0x44, 0x65, 0x73, 0x6b, 0x74, 0x6f, 0x70,
+	0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x22, 0xb5, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e,
+	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x24, 0x0a, 0x0d, 0x6e, 0x65, 0x65, 0x64,
+	0x73, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
+	0x0d, 0x6e, 0x65, 0x65, 0x64, 0x73, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1a,
+	0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x64, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x28, 0x0a, 0x0f, 0x76, 0x65,
+	0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55, 0x52, 0x49, 0x18, 0x03, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x0f, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f,
+	0x6e, 0x55, 0x52, 0x49, 0x12, 0x38, 0x0a, 0x17, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x55, 0x52, 0x49, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18,
+	0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x17, 0x76, 0x65, 0x72, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74,
+	0x69, 0x6f, 0x6e, 0x55, 0x52, 0x49, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0x31,
+	0x0a, 0x13, 0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x64,
+	0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x43, 0x6f, 0x64,
+	0x65, 0x22, 0x16, 0x0a, 0x14, 0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69,
+	0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x0b, 0x0a, 0x09, 0x55, 0x70, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x0c, 0x0a, 0x0a, 0x55, 0x70, 0x52, 0x65, 0x73, 0x70,
+	0x6f, 0x6e, 0x73, 0x65, 0x22, 0x3d, 0x0a, 0x0d, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2c, 0x0a, 0x11, 0x67, 0x65, 0x74, 0x46, 0x75, 0x6c, 0x6c,
+	0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08,
+	0x52, 0x11, 0x67, 0x65, 0x74, 0x46, 0x75, 0x6c, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61,
+	0x74, 0x75, 0x73, 0x22, 0x82, 0x01, 0x0a, 0x0e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65,
+	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x32,
+	0x0a, 0x0a, 0x66, 0x75, 0x6c, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x12, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x46, 0x75, 0x6c, 0x6c,
+	0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x0a, 0x66, 0x75, 0x6c, 0x6c, 0x53, 0x74, 0x61, 0x74,
+	0x75, 0x73, 0x12, 0x24, 0x0a, 0x0d, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x56, 0x65, 0x72, 0x73,
+	0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x64, 0x61, 0x65, 0x6d, 0x6f,
+	0x6e, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x0d, 0x0a, 0x0b, 0x44, 0x6f, 0x77, 0x6e,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x0e, 0x0a, 0x0c, 0x44, 0x6f, 0x77, 0x6e, 0x52,
+	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x12, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x43, 0x6f,
+	0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xb3, 0x01, 0x0a, 0x11,
+	0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
+	0x65, 0x12, 0x24, 0x0a, 0x0d, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x55,
+	0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x55, 0x72, 0x6c, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x66, 0x69,
+	0x67, 0x46, 0x69, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e,
+	0x66, 0x69, 0x67, 0x46, 0x69, 0x6c, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6c, 0x6f, 0x67, 0x46, 0x69,
+	0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6c, 0x6f, 0x67, 0x46, 0x69, 0x6c,
+	0x65, 0x12, 0x22, 0x0a, 0x0c, 0x70, 0x72, 0x65, 0x53, 0x68, 0x61, 0x72, 0x65, 0x64, 0x4b, 0x65,
+	0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x72, 0x65, 0x53, 0x68, 0x61, 0x72,
+	0x65, 0x64, 0x4b, 0x65, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x55, 0x52,
+	0x4c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x55, 0x52,
+	0x4c, 0x22, 0xcf, 0x02, 0x0a, 0x09, 0x50, 0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12,
+	0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x50, 0x12,
+	0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x06, 0x70, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x6e, 0x53,
+	0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x6f, 0x6e,
+	0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x46, 0x0a, 0x10, 0x63, 0x6f, 0x6e, 0x6e, 0x53,
+	0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x10, 0x63,
+	0x6f, 0x6e, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12,
+	0x18, 0x0a, 0x07, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08,
+	0x52, 0x07, 0x72, 0x65, 0x6c, 0x61, 0x79, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x69, 0x72,
+	0x65, 0x63, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x64, 0x69, 0x72, 0x65, 0x63,
+	0x74, 0x12, 0x34, 0x0a, 0x15, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x49, 0x63, 0x65, 0x43, 0x61, 0x6e,
+	0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x15, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x49, 0x63, 0x65, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64,
+	0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x36, 0x0a, 0x16, 0x72, 0x65, 0x6d, 0x6f, 0x74,
+	0x65, 0x49, 0x63, 0x65, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70,
+	0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x16, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x49,
+	0x63, 0x65, 0x43, 0x61, 0x6e, 0x64, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12,
+	0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66,
+	0x71, 0x64, 0x6e, 0x22, 0x76, 0x0a, 0x0e, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x65, 0x65, 0x72,
+	0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x50, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x02, 0x49, 0x50, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x75, 0x62, 0x4b, 0x65, 0x79, 0x12, 0x28, 0x0a,
+	0x0f, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x6b, 0x65, 0x72, 0x6e, 0x65, 0x6c, 0x49, 0x6e,
+	0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x18,
+	0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x71, 0x64, 0x6e, 0x22, 0x3d, 0x0a, 0x0b, 0x53,
+	0x69, 0x67, 0x6e, 0x61, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x55, 0x52,
+	0x4c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x55, 0x52, 0x4c, 0x12, 0x1c, 0x0a, 0x09,
+	0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52,
+	0x09, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x22, 0x41, 0x0a, 0x0f, 0x4d, 0x61,
+	0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a,
+	0x03, 0x55, 0x52, 0x4c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x55, 0x52, 0x4c, 0x12,
+	0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01,
+	0x28, 0x08, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x22, 0xef, 0x01,
+	0x0a, 0x0a, 0x46, 0x75, 0x6c, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x41, 0x0a, 0x0f,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4d,
+	0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x0f,
+	0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12,
+	0x35, 0x0a, 0x0b, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x69,
+	0x67, 0x6e, 0x61, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x0b, 0x73, 0x69, 0x67, 0x6e, 0x61,
+	0x6c, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x3e, 0x0a, 0x0e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x50,
+	0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16,
+	0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x65, 0x65,
+	0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x0e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x50, 0x65, 0x65,
+	0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x27, 0x0a, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x18,
+	0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x50,
+	0x65, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x05, 0x70, 0x65, 0x65, 0x72, 0x73, 0x32,
+	0xf7, 0x02, 0x0a, 0x0d, 0x44, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
+	0x65, 0x12, 0x36, 0x0a, 0x05, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x14, 0x2e, 0x64, 0x61, 0x65,
+	0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x1a, 0x15, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52,
+	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x0c, 0x57, 0x61, 0x69,
+	0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x12, 0x1b, 0x2e, 0x64, 0x61, 0x65, 0x6d,
+	0x6f, 0x6e, 0x2e, 0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e,
+	0x57, 0x61, 0x69, 0x74, 0x53, 0x53, 0x4f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x65, 0x73, 0x70,
+	0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x2d, 0x0a, 0x02, 0x55, 0x70, 0x12, 0x11, 0x2e, 0x64,
+	0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x55, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
+	0x12, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x55, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f,
+	0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x39, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12,
+	0x15, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e,
+	0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00,
+	0x12, 0x33, 0x0a, 0x04, 0x44, 0x6f, 0x77, 0x6e, 0x12, 0x13, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f,
+	0x6e, 0x2e, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e,
+	0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x44, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
+	0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x42, 0x0a, 0x09, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66,
+	0x69, 0x67, 0x12, 0x18, 0x2e, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x43,
+	0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x64,
+	0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52,
+	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto
index 53d921d7..8bed1ec9 100644
--- a/client/proto/daemon.proto
+++ b/client/proto/daemon.proto
@@ -51,6 +51,7 @@ message LoginRequest {
 
   bytes customDNSAddress = 7;
 
+  bool isLinuxDesktopClient = 8;
 }
 
 message LoginResponse {
diff --git a/client/server/server.go b/client/server/server.go
index 6748f62a..faac2227 100644
--- a/client/server/server.go
+++ b/client/server/server.go
@@ -208,7 +208,7 @@ func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*pro
 	state.Set(internal.StatusConnecting)
 
 	if msg.SetupKey == "" {
-		oAuthFlow, err := auth.NewOAuthFlow(ctx, config)
+		oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.IsLinuxDesktopClient)
 		if err != nil {
 			state.Set(internal.StatusLoginFailed)
 			return nil, err
diff --git a/infrastructure_files/docker-compose.yml.tmpl.traefik b/infrastructure_files/docker-compose.yml.tmpl.traefik
index 6d371081..3c18ed91 100644
--- a/infrastructure_files/docker-compose.yml.tmpl.traefik
+++ b/infrastructure_files/docker-compose.yml.tmpl.traefik
@@ -20,6 +20,7 @@ services:
       - AUTH_SUPPORTED_SCOPES=$NETBIRD_AUTH_SUPPORTED_SCOPES
       - AUTH_REDIRECT_URI=$NETBIRD_AUTH_REDIRECT_URI
       - AUTH_SILENT_REDIRECT_URI=$NETBIRD_AUTH_SILENT_REDIRECT_URI
+      - NETBIRD_TOKEN_SOURCE=$NETBIRD_TOKEN_SOURCE
       # SSL
       - NGINX_SSL_PORT=443
       # Letsencrypt
diff --git a/management/cmd/management.go b/management/cmd/management.go
index 545d6840..f85cf225 100644
--- a/management/cmd/management.go
+++ b/management/cmd/management.go
@@ -148,7 +148,7 @@ var (
 				return fmt.Errorf("failed to initialize database: %s", err)
 			}
 
-			if key != "" {
+			if config.DataStoreEncryptionKey != key {
 				log.Infof("update config with activity store key")
 				config.DataStoreEncryptionKey = key
 				err := updateMgmtConfig(mgmtConfig, config)
diff --git a/management/server/account.go b/management/server/account.go
index 2dba658e..b8bfd577 100644
--- a/management/server/account.go
+++ b/management/server/account.go
@@ -66,7 +66,7 @@ type AccountManager interface {
 	GetPeerByKey(peerKey string) (*Peer, error)
 	GetPeers(accountID, userID string) ([]*Peer, error)
 	MarkPeerConnected(peerKey string, connected bool) error
-	DeletePeer(accountID, peerID, userID string) (*Peer, error)
+	DeletePeer(accountID, peerID, userID string) error
 	GetPeerByIP(accountId string, peerIP string) (*Peer, error)
 	UpdatePeer(accountID, userID string, peer *Peer) (*Peer, error)
 	GetNetworkMap(peerID string) (*NetworkMap, error)
@@ -83,14 +83,14 @@ type AccountManager interface {
 	DeleteGroup(accountId, userId, groupID string) error
 	ListGroups(accountId string) ([]*Group, error)
 	GroupAddPeer(accountId, groupID, peerID string) error
-	GroupDeletePeer(accountId, groupID, peerKey string) error
+	GroupDeletePeer(accountId, groupID, peerID string) error
 	GroupListPeers(accountId, groupID string) ([]*Peer, error)
 	GetPolicy(accountID, policyID, userID string) (*Policy, error)
 	SavePolicy(accountID, userID string, policy *Policy) error
 	DeletePolicy(accountID, policyID, userID string) error
 	ListPolicies(accountID, userID string) ([]*Policy, error)
 	GetRoute(accountID, routeID, userID string) (*route.Route, error)
-	CreateRoute(accountID string, prefix, peerID, description, netID string, masquerade bool, metric int, groups []string, enabled bool, userID string) (*route.Route, error)
+	CreateRoute(accountID, prefix, peerID string, peerGroupIDs []string, description, netID string, masquerade bool, metric int, groups []string, enabled bool, userID string) (*route.Route, error)
 	SaveRoute(accountID, userID string, route *route.Route) error
 	DeleteRoute(accountID, routeID, userID string) error
 	ListRoutes(accountID, userID string) ([]*route.Route, error)
@@ -253,22 +253,39 @@ func (a *Account) filterRoutesByGroups(routes []*route.Route, groupListMap looku
 func (a *Account) getEnabledAndDisabledRoutesByPeer(peerID string) ([]*route.Route, []*route.Route) {
 	var enabledRoutes []*route.Route
 	var disabledRoutes []*route.Route
+
+	takeRoute := func(r *route.Route, id string) {
+		peer := a.GetPeer(peerID)
+		if peer == nil {
+			log.Errorf("route %s has peer %s that doesn't exist under account %s", r.ID, peerID, a.Id)
+			return
+		}
+
+		if r.Enabled {
+			enabledRoutes = append(enabledRoutes, r)
+			return
+		}
+		disabledRoutes = append(disabledRoutes, r)
+	}
+
 	for _, r := range a.Routes {
-		if r.Peer == peerID {
-			// We need to set Peer.Key instead of Peer.ID because this object will be sent to agents as part of a network map.
-			// Ideally we should have a separate field for that, but fine for now.
-			peer := a.GetPeer(peerID)
-			if peer == nil {
-				log.Errorf("route %s has peer %s that doesn't exist under account %s", r.ID, peerID, a.Id)
-				continue
-			}
-			raut := r.Copy()
-			raut.Peer = peer.Key
-			if r.Enabled {
-				enabledRoutes = append(enabledRoutes, raut)
-				continue
+		if len(r.PeerGroups) != 0 {
+			for _, groupID := range r.PeerGroups {
+				group := a.GetGroup(groupID)
+				if group == nil {
+					log.Errorf("route %s has peers group %s that doesn't exist under account %s", r.ID, groupID, a.Id)
+					continue
+				}
+				for _, id := range group.Peers {
+					if id == peerID {
+						takeRoute(r, id)
+						break
+					}
+				}
 			}
-			disabledRoutes = append(disabledRoutes, raut)
+		}
+		if r.Peer == peerID {
+			takeRoute(r, peerID)
 		}
 	}
 	return enabledRoutes, disabledRoutes
@@ -316,8 +333,51 @@ func (a *Account) GetPeerNetworkMap(peerID, dnsDomain string) *NetworkMap {
 		}
 		peersToConnect = append(peersToConnect, p)
 	}
-	// Please mind, that the returned route.Route objects will contain Peer.Key instead of Peer.ID.
-	routesUpdate := a.getRoutesToSync(peerID, peersToConnect)
+
+	routes := a.getRoutesToSync(peerID, peersToConnect)
+
+	takePeer := func(id string) (*Peer, bool) {
+		peer := a.GetPeer(id)
+		if peer == nil || peer.Meta.GoOS != "linux" {
+			return nil, false
+		}
+		return peer, true
+	}
+
+	// We need to set Peer.Key instead of Peer.ID because this object will be sent to agents as part of a network map.
+	// Ideally we should have a separate field for that, but fine for now.
+	var routesUpdate []*route.Route
+	seenPeers := make(map[string]bool)
+	for _, r := range routes {
+		if r.Peer != "" {
+			peer, valid := takePeer(r.Peer)
+			if !valid {
+				continue
+			}
+			rCopy := r.Copy()
+			rCopy.Peer = peer.Key // client expects the key
+			routesUpdate = append(routesUpdate, rCopy)
+			continue
+		}
+		for _, groupID := range r.PeerGroups {
+			if group := a.GetGroup(groupID); group != nil {
+				for _, peerId := range group.Peers {
+					peer, valid := takePeer(peerId)
+					if !valid {
+						continue
+					}
+
+					if _, ok := seenPeers[peer.ID]; !ok {
+						rCopy := r.Copy()
+						rCopy.ID = r.ID + ":" + peer.ID // we have to provide unit route id when distribute network map
+						rCopy.Peer = peer.Key           // client expects the key
+						routesUpdate = append(routesUpdate, rCopy)
+					}
+					seenPeers[peer.ID] = true
+				}
+			}
+		}
+	}
 
 	dnsManagementStatus := a.getPeerDNSManagementStatus(peerID)
 	dnsUpdate := nbdns.Config{
@@ -577,8 +637,8 @@ func (a *Account) Copy() *Account {
 	}
 
 	routes := map[string]*route.Route{}
-	for id, route := range a.Routes {
-		routes[id] = route.Copy()
+	for id, r := range a.Routes {
+		routes[id] = r.Copy()
 	}
 
 	nsGroups := map[string]*nbdns.NameServerGroup{}
@@ -928,6 +988,27 @@ func (am *DefaultAccountManager) warmupIDPCache() error {
 		return err
 	}
 
+	// If the Identity Provider does not support writing AppMetadata,
+	// in cases like this, we expect it to return all users in an "unset" field.
+	// We iterate over the users in the "unset" field, look up their AccountID in our store, and
+	// update their AppMetadata with the AccountID.
+	if unsetData, ok := userData[idp.UnsetAccountID]; ok {
+		for _, user := range unsetData {
+			accountID, err := am.Store.GetAccountByUser(user.ID)
+			if err == nil {
+				data := userData[accountID.Id]
+				if data == nil {
+					data = make([]*idp.UserData, 0, 1)
+				}
+
+				user.AppMetadata.WTAccountID = accountID.Id
+
+				userData[accountID.Id] = append(data, user)
+			}
+		}
+	}
+	delete(userData, idp.UnsetAccountID)
+
 	for accountID, users := range userData {
 		err = am.cacheManager.Set(am.ctx, accountID, users, cacheStore.WithExpiration(cacheEntryExpiration()))
 		if err != nil {
@@ -994,7 +1075,36 @@ func (am *DefaultAccountManager) addAccountIDToIDPAppMeta(userID string, account
 
 func (am *DefaultAccountManager) loadAccount(_ context.Context, accountID interface{}) ([]*idp.UserData, error) {
 	log.Debugf("account %s not found in cache, reloading", accountID)
-	return am.idpManager.GetAccount(fmt.Sprintf("%v", accountID))
+	accountIDString := fmt.Sprintf("%v", accountID)
+
+	account, err := am.Store.GetAccount(accountIDString)
+	if err != nil {
+		return nil, err
+	}
+
+	userData, err := am.idpManager.GetAccount(accountIDString)
+	if err != nil {
+		return nil, err
+	}
+
+	dataMap := make(map[string]*idp.UserData, len(userData))
+	for _, datum := range userData {
+		dataMap[datum.ID] = datum
+	}
+
+	matchedUserData := make([]*idp.UserData, 0)
+	for _, user := range account.Users {
+		if user.IsServiceUser {
+			continue
+		}
+		datum, ok := dataMap[user.Id]
+		if !ok {
+			log.Warnf("user %s not found in IDP", user.Id)
+			continue
+		}
+		matchedUserData = append(matchedUserData, datum)
+	}
+	return matchedUserData, nil
 }
 
 func (am *DefaultAccountManager) lookupUserInCacheByEmail(email string, accountID string) (*idp.UserData, error) {
@@ -1243,7 +1353,6 @@ func (am *DefaultAccountManager) redeemInvite(account *Account, userID string) e
 
 // MarkPATUsed marks a personal access token as used
 func (am *DefaultAccountManager) MarkPATUsed(tokenID string) error {
-	unlock := am.Store.AcquireGlobalLock()
 
 	user, err := am.Store.GetUserByTokenID(tokenID)
 	if err != nil {
@@ -1255,8 +1364,7 @@ func (am *DefaultAccountManager) MarkPATUsed(tokenID string) error {
 		return err
 	}
 
-	unlock()
-	unlock = am.Store.AcquireAccountLock(account.Id)
+	unlock := am.Store.AcquireAccountLock(account.Id)
 	defer unlock()
 
 	account, err = am.Store.GetAccountByUser(user.Id)
diff --git a/management/server/ephemeral.go b/management/server/ephemeral.go
index a7b42398..0e76e58a 100644
--- a/management/server/ephemeral.go
+++ b/management/server/ephemeral.go
@@ -162,7 +162,7 @@ func (e *EphemeralManager) cleanup() {
 
 	for id, p := range deletePeers {
 		log.Debugf("delete ephemeral peer: %s", id)
-		_, err := e.accountManager.DeletePeer(p.account.Id, id, activity.SystemInitiator)
+		err := e.accountManager.DeletePeer(p.account.Id, id, activity.SystemInitiator)
 		if err != nil {
 			log.Tracef("failed to delete ephemeral peer: %s", err)
 		}
diff --git a/management/server/group.go b/management/server/group.go
index 697fe5d7..0aebc645 100644
--- a/management/server/group.go
+++ b/management/server/group.go
@@ -285,7 +285,7 @@ func (am *DefaultAccountManager) GroupAddPeer(accountID, groupID, peerID string)
 }
 
 // GroupDeletePeer removes peer from the group
-func (am *DefaultAccountManager) GroupDeletePeer(accountID, groupID, peerKey string) error {
+func (am *DefaultAccountManager) GroupDeletePeer(accountID, groupID, peerID string) error {
 	unlock := am.Store.AcquireAccountLock(accountID)
 	defer unlock()
 
@@ -301,7 +301,7 @@ func (am *DefaultAccountManager) GroupDeletePeer(accountID, groupID, peerKey str
 
 	account.Network.IncSerial()
 	for i, itemID := range group.Peers {
-		if itemID == peerKey {
+		if itemID == peerID {
 			group.Peers = append(group.Peers[:i], group.Peers[i+1:]...)
 			if err := am.Store.SaveAccount(account); err != nil {
 				return err
diff --git a/management/server/http/api/openapi.yml b/management/server/http/api/openapi.yml
index db256148..658d389f 100644
--- a/management/server/http/api/openapi.yml
+++ b/management/server/http/api/openapi.yml
@@ -745,9 +745,15 @@ components:
           type: boolean
           example: true
         peer:
-          description: Peer Identifier associated with route
+          description: Peer Identifier associated with route. This property can not be set together with `peer_groups`
           type: string
           example: chacbco6lnnbn6cg5s91
+        peer_groups:
+          description: Peers Group Identifier associated with route. This property can not be set together with `peer`
+          type: array
+          items:
+            type: string
+            example: chacbco6lnnbn6cg5s91
         network:
           description: Network range in CIDR format
           type: string
@@ -773,7 +779,9 @@ components:
         - description
         - network_id
         - enabled
-        - peer
+        # Only one property has to be set
+        #- peer
+        #- peer_groups
         - network
         - metric
         - masquerade
@@ -1144,8 +1152,8 @@ paths:
         '500':
           "$ref": "#/components/responses/internal_error"
     delete:
-      summary: Block a User
-      description: This method blocks a user from accessing the system, but leaves the IDP user intact.
+      summary: Delete a User
+      description: This method removes a user from accessing the system. For this leaves the IDP user intact unless the `--user-delete-from-idp` is passed to management startup.
       tags: [ Users ]
       security:
         - BearerAuth: [ ]
diff --git a/management/server/http/api/types.gen.go b/management/server/http/api/types.gen.go
index dec73630..fd3eedde 100644
--- a/management/server/http/api/types.gen.go
+++ b/management/server/http/api/types.gen.go
@@ -1,6 +1,6 @@
 // Package api provides primitives to interact with the openapi HTTP API.
 //
-// Code generated by github.com/deepmap/oapi-codegen version v1.11.1-0.20220912230023-4a1477f6a8ba DO NOT EDIT.
+// Code generated by github.com/deepmap/oapi-codegen version v1.15.0 DO NOT EDIT.
 package api
 
 import (
@@ -599,8 +599,11 @@ type Route struct {
 	// NetworkType Network type indicating if it is IPv4 or IPv6
 	NetworkType string `json:"network_type"`
 
-	// Peer Peer Identifier associated with route
-	Peer string `json:"peer"`
+	// Peer Peer Identifier associated with route. This property can not be set together with `peer_groups`
+	Peer *string `json:"peer,omitempty"`
+
+	// PeerGroups Peers Group Identifier associated with route. This property can not be set together with `peer`
+	PeerGroups *[]string `json:"peer_groups,omitempty"`
 }
 
 // RouteRequest defines model for RouteRequest.
@@ -626,8 +629,11 @@ type RouteRequest struct {
 	// NetworkId Route network identifier, to group HA routes
 	NetworkId string `json:"network_id"`
 
-	// Peer Peer Identifier associated with route
-	Peer string `json:"peer"`
+	// Peer Peer Identifier associated with route. This property can not be set together with `peer_groups`
+	Peer *string `json:"peer,omitempty"`
+
+	// PeerGroups Peers Group Identifier associated with route. This property can not be set together with `peer`
+	PeerGroups *[]string `json:"peer_groups,omitempty"`
 }
 
 // Rule defines model for Rule.
diff --git a/management/server/http/peers_handler.go b/management/server/http/peers_handler.go
index 100549aa..adf4a972 100644
--- a/management/server/http/peers_handler.go
+++ b/management/server/http/peers_handler.go
@@ -61,7 +61,7 @@ func (h *PeersHandler) updatePeer(account *server.Account, user *server.User, pe
 }
 
 func (h *PeersHandler) deletePeer(accountID, userID string, peerID string, w http.ResponseWriter) {
-	_, err := h.accountManager.DeletePeer(accountID, peerID, userID)
+	err := h.accountManager.DeletePeer(accountID, peerID, userID)
 	if err != nil {
 		util.WriteError(err, w)
 		return
diff --git a/management/server/http/routes_handler.go b/management/server/http/routes_handler.go
index a6dfa9c7..348bdbfd 100644
--- a/management/server/http/routes_handler.go
+++ b/management/server/http/routes_handler.go
@@ -82,7 +82,33 @@ func (h *RoutesHandler) CreateRoute(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	newRoute, err := h.accountManager.CreateRoute(account.Id, newPrefix.String(), req.Peer, req.Description, req.NetworkId, req.Masquerade, req.Metric, req.Groups, req.Enabled, user.Id)
+	peerId := ""
+	if req.Peer != nil {
+		peerId = *req.Peer
+	}
+
+	peerGroupIds := []string{}
+	if req.PeerGroups != nil {
+		peerGroupIds = *req.PeerGroups
+	}
+
+	if (peerId != "" && len(peerGroupIds) > 0) || (peerId == "" && len(peerGroupIds) == 0) {
+		util.WriteError(status.Errorf(status.InvalidArgument, "only one peer or peer_groups should be provided"), w)
+		return
+	}
+
+	// do not allow non Linux peers
+	if peer := account.GetPeer(peerId); peer != nil {
+		if peer.Meta.GoOS != "linux" {
+			util.WriteError(status.Errorf(status.InvalidArgument, "non-linux peers are non supported as network routes"), w)
+			return
+		}
+	}
+
+	newRoute, err := h.accountManager.CreateRoute(
+		account.Id, newPrefix.String(), peerId, peerGroupIds,
+		req.Description, req.NetworkId, req.Masquerade, req.Metric, req.Groups, req.Enabled, user.Id,
+	)
 	if err != nil {
 		util.WriteError(err, w)
 		return
@@ -135,19 +161,49 @@ func (h *RoutesHandler) UpdateRoute(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	if req.Peer != nil && req.PeerGroups != nil {
+		util.WriteError(status.Errorf(status.InvalidArgument, "only peer or peers_group should be provided"), w)
+		return
+	}
+
+	if req.Peer == nil && req.PeerGroups == nil {
+		util.WriteError(status.Errorf(status.InvalidArgument, "either peer or peers_group should be provided"), w)
+		return
+	}
+
+	peerID := ""
+	if req.Peer != nil {
+		peerID = *req.Peer
+	}
+
+	// do not allow non Linux peers
+	if peer := account.GetPeer(peerID); peer != nil {
+		if peer.Meta.GoOS != "linux" {
+			util.WriteError(status.Errorf(status.InvalidArgument, "non-linux peers are non supported as network routes"), w)
+			return
+		}
+	}
+
 	newRoute := &route.Route{
 		ID:          routeID,
 		Network:     newPrefix,
 		NetID:       req.NetworkId,
 		NetworkType: prefixType,
 		Masquerade:  req.Masquerade,
-		Peer:        req.Peer,
 		Metric:      req.Metric,
 		Description: req.Description,
 		Enabled:     req.Enabled,
 		Groups:      req.Groups,
 	}
 
+	if req.Peer != nil {
+		newRoute.Peer = peerID
+	}
+
+	if req.PeerGroups != nil {
+		newRoute.PeerGroups = *req.PeerGroups
+	}
+
 	err = h.accountManager.SaveRoute(account.Id, user.Id, newRoute)
 	if err != nil {
 		util.WriteError(err, w)
@@ -208,16 +264,21 @@ func (h *RoutesHandler) GetRoute(w http.ResponseWriter, r *http.Request) {
 }
 
 func toRouteResponse(serverRoute *route.Route) *api.Route {
-	return &api.Route{
+	route := &api.Route{
 		Id:          serverRoute.ID,
 		Description: serverRoute.Description,
 		NetworkId:   serverRoute.NetID,
 		Enabled:     serverRoute.Enabled,
-		Peer:        serverRoute.Peer,
+		Peer:        &serverRoute.Peer,
 		Network:     serverRoute.Network.String(),
 		NetworkType: serverRoute.NetworkType.String(),
 		Masquerade:  serverRoute.Masquerade,
 		Metric:      serverRoute.Metric,
 		Groups:      serverRoute.Groups,
 	}
+
+	if len(serverRoute.PeerGroups) > 0 {
+		route.PeerGroups = &serverRoute.PeerGroups
+	}
+	return route
 }
diff --git a/management/server/idp/authentik.go b/management/server/idp/authentik.go
index 102222d0..ca995b29 100644
--- a/management/server/idp/authentik.go
+++ b/management/server/idp/authentik.go
@@ -210,47 +210,7 @@ func (ac *AuthentikCredentials) Authenticate() (JWTToken, error) {
 }
 
 // UpdateUserAppMetadata updates user app metadata based on userID and metadata map.
-func (am *AuthentikManager) UpdateUserAppMetadata(userID string, appMetadata AppMetadata) error {
-	ctx, err := am.authenticationContext()
-	if err != nil {
-		return err
-	}
-
-	userPk, err := strconv.ParseInt(userID, 10, 32)
-	if err != nil {
-		return err
-	}
-
-	var pendingInvite bool
-	if appMetadata.WTPendingInvite != nil {
-		pendingInvite = *appMetadata.WTPendingInvite
-	}
-
-	patchedUserReq := api.PatchedUserRequest{
-		Attributes: map[string]interface{}{
-			wtAccountID:     appMetadata.WTAccountID,
-			wtPendingInvite: pendingInvite,
-		},
-	}
-	_, resp, err := am.apiClient.CoreApi.CoreUsersPartialUpdate(ctx, int32(userPk)).
-		PatchedUserRequest(patchedUserReq).
-		Execute()
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-
-	if am.appMetrics != nil {
-		am.appMetrics.IDPMetrics().CountUpdateUserAppMetadata()
-	}
-
-	if resp.StatusCode != http.StatusOK {
-		if am.appMetrics != nil {
-			am.appMetrics.IDPMetrics().CountRequestStatusError()
-		}
-		return fmt.Errorf("unable to update user %s, statusCode %d", userID, resp.StatusCode)
-	}
-
+func (am *AuthentikManager) UpdateUserAppMetadata(_ string, _ AppMetadata) error {
 	return nil
 }
 
@@ -283,7 +243,10 @@ func (am *AuthentikManager) GetUserDataByID(userID string, appMetadata AppMetada
 		return nil, fmt.Errorf("unable to get user %s, statusCode %d", userID, resp.StatusCode)
 	}
 
-	return parseAuthentikUser(*user)
+	userData := parseAuthentikUser(*user)
+	userData.AppMetadata = appMetadata
+
+	return userData, nil
 }
 
 // GetAccount returns all the users for a given profile.
@@ -293,8 +256,7 @@ func (am *AuthentikManager) GetAccount(accountID string) ([]*UserData, error) {
 		return nil, err
 	}
 
-	accountFilter := fmt.Sprintf("{%q:%q}", wtAccountID, accountID)
-	userList, resp, err := am.apiClient.CoreApi.CoreUsersList(ctx).Attributes(accountFilter).Execute()
+	userList, resp, err := am.apiClient.CoreApi.CoreUsersList(ctx).Execute()
 	if err != nil {
 		return nil, err
 	}
@@ -313,10 +275,9 @@ func (am *AuthentikManager) GetAccount(accountID string) ([]*UserData, error) {
 
 	users := make([]*UserData, 0)
 	for _, user := range userList.Results {
-		userData, err := parseAuthentikUser(user)
-		if err != nil {
-			return nil, err
-		}
+		userData := parseAuthentikUser(user)
+		userData.AppMetadata.WTAccountID = accountID
+
 		users = append(users, userData)
 	}
 
@@ -350,65 +311,16 @@ func (am *AuthentikManager) GetAllAccounts() (map[string][]*UserData, error) {
 
 	indexedUsers := make(map[string][]*UserData)
 	for _, user := range userList.Results {
-		userData, err := parseAuthentikUser(user)
-		if err != nil {
-			return nil, err
-		}
-
-		accountID := userData.AppMetadata.WTAccountID
-		if accountID != "" {
-			if _, ok := indexedUsers[accountID]; !ok {
-				indexedUsers[accountID] = make([]*UserData, 0)
-			}
-			indexedUsers[accountID] = append(indexedUsers[accountID], userData)
-		}
+		userData := parseAuthentikUser(user)
+		indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], userData)
 	}
 
 	return indexedUsers, nil
 }
 
 // CreateUser creates a new user in authentik Idp and sends an invitation.
-func (am *AuthentikManager) CreateUser(email, name, accountID, invitedByEmail string) (*UserData, error) {
-	ctx, err := am.authenticationContext()
-	if err != nil {
-		return nil, err
-	}
-
-	groupID, err := am.getUserGroupByName("netbird")
-	if err != nil {
-		return nil, err
-	}
-
-	defaultBoolValue := true
-	createUserRequest := api.UserRequest{
-		Email:    &email,
-		Name:     name,
-		IsActive: &defaultBoolValue,
-		Groups:   []string{groupID},
-		Username: email,
-		Attributes: map[string]interface{}{
-			wtAccountID:     accountID,
-			wtPendingInvite: &defaultBoolValue,
-		},
-	}
-	user, resp, err := am.apiClient.CoreApi.CoreUsersCreate(ctx).UserRequest(createUserRequest).Execute()
-	if err != nil {
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	if am.appMetrics != nil {
-		am.appMetrics.IDPMetrics().CountCreateUser()
-	}
-
-	if resp.StatusCode != http.StatusCreated {
-		if am.appMetrics != nil {
-			am.appMetrics.IDPMetrics().CountRequestStatusError()
-		}
-		return nil, fmt.Errorf("unable to create user, statusCode %d", resp.StatusCode)
-	}
-
-	return parseAuthentikUser(*user)
+func (am *AuthentikManager) CreateUser(_, _, _, _ string) (*UserData, error) {
+	return nil, fmt.Errorf("method CreateUser not implemented")
 }
 
 // GetUserByEmail searches users with a given email.
@@ -438,11 +350,7 @@ func (am *AuthentikManager) GetUserByEmail(email string) ([]*UserData, error) {
 
 	users := make([]*UserData, 0)
 	for _, user := range userList.Results {
-		userData, err := parseAuthentikUser(user)
-		if err != nil {
-			return nil, err
-		}
-		users = append(users, userData)
+		users = append(users, parseAuthentikUser(user))
 	}
 
 	return users, nil
@@ -501,64 +409,10 @@ func (am *AuthentikManager) authenticationContext() (context.Context, error) {
 	return context.WithValue(context.Background(), api.ContextAPIKeys, value), nil
 }
 
-// getUserGroupByName retrieves the user group for assigning new users.
-// If the group is not found, a new group with the specified name will be created.
-func (am *AuthentikManager) getUserGroupByName(name string) (string, error) {
-	ctx, err := am.authenticationContext()
-	if err != nil {
-		return "", err
-	}
-
-	groupList, resp, err := am.apiClient.CoreApi.CoreGroupsList(ctx).Name(name).Execute()
-	if err != nil {
-		return "", err
-	}
-	defer resp.Body.Close()
-
-	if groupList != nil {
-		if len(groupList.Results) > 0 {
-			return groupList.Results[0].Pk, nil
-		}
-	}
-
-	createGroupRequest := api.GroupRequest{Name: name}
-	group, resp, err := am.apiClient.CoreApi.CoreGroupsCreate(ctx).GroupRequest(createGroupRequest).Execute()
-	if err != nil {
-		return "", err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusCreated {
-		return "", fmt.Errorf("unable to create user group, statusCode: %d", resp.StatusCode)
-	}
-
-	return group.Pk, nil
-}
-
-func parseAuthentikUser(user api.User) (*UserData, error) {
-	var attributes struct {
-		AccountID     string `json:"wt_account_id"`
-		PendingInvite bool   `json:"wt_pending_invite"`
-	}
-
-	helper := JsonParser{}
-	buf, err := helper.Marshal(user.Attributes)
-	if err != nil {
-		return nil, err
-	}
-
-	err = helper.Unmarshal(buf, &attributes)
-	if err != nil {
-		return nil, err
-	}
-
+func parseAuthentikUser(user api.User) *UserData {
 	return &UserData{
 		Email: *user.Email,
 		Name:  user.Name,
 		ID:    strconv.FormatInt(int64(user.Pk), 10),
-		AppMetadata: AppMetadata{
-			WTAccountID:     attributes.AccountID,
-			WTPendingInvite: &attributes.PendingInvite,
-		},
-	}, nil
+	}
 }
diff --git a/management/server/idp/azure.go b/management/server/idp/azure.go
index 22e6825a..e4224c26 100644
--- a/management/server/idp/azure.go
+++ b/management/server/idp/azure.go
@@ -1,7 +1,6 @@
 package idp
 
 import (
-	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
@@ -11,19 +10,13 @@ import (
 	"time"
 
 	"github.com/golang-jwt/jwt"
-	"github.com/netbirdio/netbird/management/server/telemetry"
 	log "github.com/sirupsen/logrus"
-)
-
-const (
-	// azure extension properties template
-	wtAccountIDTpl     = "extension_%s_wt_account_id"
-	wtPendingInviteTpl = "extension_%s_wt_pending_invite"
 
-	profileFields   = "id,displayName,mail,userPrincipalName"
-	extensionFields = "id,name,targetObjects"
+	"github.com/netbirdio/netbird/management/server/telemetry"
 )
 
+const profileFields = "id,displayName,mail,userPrincipalName"
+
 // AzureManager azure manager client instance.
 type AzureManager struct {
 	ClientID         string
@@ -58,21 +51,6 @@ type AzureCredentials struct {
 // azureProfile represents an azure user profile.
 type azureProfile map[string]any
 
-// passwordProfile represent authentication method for,
-// newly created user profile.
-type passwordProfile struct {
-	ForceChangePasswordNextSignIn bool   `json:"forceChangePasswordNextSignIn"`
-	Password                      string `json:"password"`
-}
-
-// azureExtension represent custom attribute,
-// that can be added to user objects in Azure Active Directory (AD).
-type azureExtension struct {
-	Name          string   `json:"name"`
-	DataType      string   `json:"dataType"`
-	TargetObjects []string `json:"targetObjects"`
-}
-
 // NewAzureManager creates a new instance of the AzureManager.
 func NewAzureManager(config AzureClientConfig, appMetrics telemetry.AppMetrics) (*AzureManager, error) {
 	httpTransport := http.DefaultTransport.(*http.Transport).Clone()
@@ -115,7 +93,7 @@ func NewAzureManager(config AzureClientConfig, appMetrics telemetry.AppMetrics)
 		appMetrics:   appMetrics,
 	}
 
-	manager := &AzureManager{
+	return &AzureManager{
 		ObjectID:         config.ObjectID,
 		ClientID:         config.ClientID,
 		GraphAPIEndpoint: config.GraphAPIEndpoint,
@@ -123,14 +101,7 @@ func NewAzureManager(config AzureClientConfig, appMetrics telemetry.AppMetrics)
 		credentials:      credentials,
 		helper:           helper,
 		appMetrics:       appMetrics,
-	}
-
-	err := manager.configureAppMetadata()
-	if err != nil {
-		return nil, err
-	}
-
-	return manager, nil
+	}, nil
 }
 
 // jwtStillValid returns true if the token still valid and have enough time to be used and get a response from azure.
@@ -236,44 +207,14 @@ func (ac *AzureCredentials) Authenticate() (JWTToken, error) {
 }
 
 // CreateUser creates a new user in azure AD Idp.
-func (am *AzureManager) CreateUser(email, name, accountID, invitedByEmail string) (*UserData, error) {
-	payload, err := buildAzureCreateUserRequestPayload(email, name, accountID, am.ClientID)
-	if err != nil {
-		return nil, err
-	}
-
-	body, err := am.post("users", payload)
-	if err != nil {
-		return nil, err
-	}
-
-	if am.appMetrics != nil {
-		am.appMetrics.IDPMetrics().CountCreateUser()
-	}
-
-	var profile azureProfile
-	err = am.helper.Unmarshal(body, &profile)
-	if err != nil {
-		return nil, err
-	}
-
-	wtAccountIDField := extensionName(wtAccountIDTpl, am.ClientID)
-	profile[wtAccountIDField] = accountID
-
-	wtPendingInviteField := extensionName(wtPendingInviteTpl, am.ClientID)
-	profile[wtPendingInviteField] = true
-
-	return profile.userData(am.ClientID), nil
+func (am *AzureManager) CreateUser(_, _, _, _ string) (*UserData, error) {
+	return nil, fmt.Errorf("method CreateUser not implemented")
 }
 
 // GetUserDataByID requests user data from keycloak via ID.
 func (am *AzureManager) GetUserDataByID(userID string, appMetadata AppMetadata) (*UserData, error) {
-	wtAccountIDField := extensionName(wtAccountIDTpl, am.ClientID)
-	wtPendingInviteField := extensionName(wtPendingInviteTpl, am.ClientID)
-	selectFields := strings.Join([]string{profileFields, wtAccountIDField, wtPendingInviteField}, ",")
-
 	q := url.Values{}
-	q.Add("$select", selectFields)
+	q.Add("$select", profileFields)
 
 	body, err := am.get("users/"+userID, q)
 	if err != nil {
@@ -290,18 +231,17 @@ func (am *AzureManager) GetUserDataByID(userID string, appMetadata AppMetadata)
 		return nil, err
 	}
 
-	return profile.userData(am.ClientID), nil
+	userData := profile.userData()
+	userData.AppMetadata = appMetadata
+
+	return userData, nil
 }
 
 // GetUserByEmail searches users with a given email.
 // If no users have been found, this function returns an empty list.
 func (am *AzureManager) GetUserByEmail(email string) ([]*UserData, error) {
-	wtAccountIDField := extensionName(wtAccountIDTpl, am.ClientID)
-	wtPendingInviteField := extensionName(wtPendingInviteTpl, am.ClientID)
-	selectFields := strings.Join([]string{profileFields, wtAccountIDField, wtPendingInviteField}, ",")
-
 	q := url.Values{}
-	q.Add("$select", selectFields)
+	q.Add("$select", profileFields)
 
 	body, err := am.get("users/"+email, q)
 	if err != nil {
@@ -319,20 +259,15 @@ func (am *AzureManager) GetUserByEmail(email string) ([]*UserData, error) {
 	}
 
 	users := make([]*UserData, 0)
-	users = append(users, profile.userData(am.ClientID))
+	users = append(users, profile.userData())
 
 	return users, nil
 }
 
 // GetAccount returns all the users for a given profile.
 func (am *AzureManager) GetAccount(accountID string) ([]*UserData, error) {
-	wtAccountIDField := extensionName(wtAccountIDTpl, am.ClientID)
-	wtPendingInviteField := extensionName(wtPendingInviteTpl, am.ClientID)
-	selectFields := strings.Join([]string{profileFields, wtAccountIDField, wtPendingInviteField}, ",")
-
 	q := url.Values{}
-	q.Add("$select", selectFields)
-	q.Add("$filter", fmt.Sprintf("%s eq '%s'", wtAccountIDField, accountID))
+	q.Add("$select", profileFields)
 
 	body, err := am.get("users", q)
 	if err != nil {
@@ -351,7 +286,10 @@ func (am *AzureManager) GetAccount(accountID string) ([]*UserData, error) {
 
 	users := make([]*UserData, 0)
 	for _, profile := range profiles.Value {
-		users = append(users, profile.userData(am.ClientID))
+		userData := profile.userData()
+		userData.AppMetadata.WTAccountID = accountID
+
+		users = append(users, userData)
 	}
 
 	return users, nil
@@ -360,12 +298,8 @@ func (am *AzureManager) GetAccount(accountID string) ([]*UserData, error) {
 // GetAllAccounts gets all registered accounts with corresponding user data.
 // It returns a list of users indexed by accountID.
 func (am *AzureManager) GetAllAccounts() (map[string][]*UserData, error) {
-	wtAccountIDField := extensionName(wtAccountIDTpl, am.ClientID)
-	wtPendingInviteField := extensionName(wtPendingInviteTpl, am.ClientID)
-	selectFields := strings.Join([]string{profileFields, wtAccountIDField, wtPendingInviteField}, ",")
-
 	q := url.Values{}
-	q.Add("$select", selectFields)
+	q.Add("$select", profileFields)
 
 	body, err := am.get("users", q)
 	if err != nil {
@@ -384,67 +318,15 @@ func (am *AzureManager) GetAllAccounts() (map[string][]*UserData, error) {
 
 	indexedUsers := make(map[string][]*UserData)
 	for _, profile := range profiles.Value {
-		userData := profile.userData(am.ClientID)
-
-		accountID := userData.AppMetadata.WTAccountID
-		if accountID != "" {
-			if _, ok := indexedUsers[accountID]; !ok {
-				indexedUsers[accountID] = make([]*UserData, 0)
-			}
-			indexedUsers[accountID] = append(indexedUsers[accountID], userData)
-		}
-
+		userData := profile.userData()
+		indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], userData)
 	}
 
 	return indexedUsers, nil
 }
 
 // UpdateUserAppMetadata updates user app metadata based on userID.
-func (am *AzureManager) UpdateUserAppMetadata(userID string, appMetadata AppMetadata) error {
-	jwtToken, err := am.credentials.Authenticate()
-	if err != nil {
-		return err
-	}
-
-	wtAccountIDField := extensionName(wtAccountIDTpl, am.ClientID)
-	wtPendingInviteField := extensionName(wtPendingInviteTpl, am.ClientID)
-
-	data, err := am.helper.Marshal(map[string]any{
-		wtAccountIDField:     appMetadata.WTAccountID,
-		wtPendingInviteField: appMetadata.WTPendingInvite,
-	})
-	if err != nil {
-		return err
-	}
-	payload := strings.NewReader(string(data))
-
-	reqURL := fmt.Sprintf("%s/users/%s", am.GraphAPIEndpoint, userID)
-	req, err := http.NewRequest(http.MethodPatch, reqURL, payload)
-	if err != nil {
-		return err
-	}
-	req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken)
-	req.Header.Add("content-type", "application/json")
-
-	log.Debugf("updating idp metadata for 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().CountUpdateUserAppMetadata()
-	}
-
-	if resp.StatusCode != http.StatusNoContent {
-		return fmt.Errorf("unable to update the appMetadata, statusCode %d", resp.StatusCode)
-	}
-
+func (am *AzureManager) UpdateUserAppMetadata(_ string, _ AppMetadata) error {
 	return nil
 }
 
@@ -454,7 +336,7 @@ func (am *AzureManager) InviteUserByID(_ string) error {
 	return fmt.Errorf("method InviteUserByID not implemented")
 }
 
-// DeleteUser from Azure
+// DeleteUser from Azure.
 func (am *AzureManager) DeleteUser(userID string) error {
 	jwtToken, err := am.credentials.Authenticate()
 	if err != nil {
@@ -491,81 +373,6 @@ func (am *AzureManager) DeleteUser(userID string) error {
 	return nil
 }
 
-func (am *AzureManager) getUserExtensions() ([]azureExtension, error) {
-	q := url.Values{}
-	q.Add("$select", extensionFields)
-
-	resource := fmt.Sprintf("applications/%s/extensionProperties", am.ObjectID)
-	body, err := am.get(resource, q)
-	if err != nil {
-		return nil, err
-	}
-
-	var extensions struct{ Value []azureExtension }
-	err = am.helper.Unmarshal(body, &extensions)
-	if err != nil {
-		return nil, err
-	}
-
-	return extensions.Value, nil
-}
-
-func (am *AzureManager) createUserExtension(name string) (*azureExtension, error) {
-	extension := azureExtension{
-		Name:          name,
-		DataType:      "string",
-		TargetObjects: []string{"User"},
-	}
-
-	payload, err := am.helper.Marshal(extension)
-	if err != nil {
-		return nil, err
-	}
-
-	resource := fmt.Sprintf("applications/%s/extensionProperties", am.ObjectID)
-	body, err := am.post(resource, string(payload))
-	if err != nil {
-		return nil, err
-	}
-
-	var userExtension azureExtension
-	err = am.helper.Unmarshal(body, &userExtension)
-	if err != nil {
-		return nil, err
-	}
-
-	return &userExtension, nil
-}
-
-// configureAppMetadata sets up app metadata extensions if they do not exists.
-func (am *AzureManager) configureAppMetadata() error {
-	wtAccountIDField := extensionName(wtAccountIDTpl, am.ClientID)
-	wtPendingInviteField := extensionName(wtPendingInviteTpl, am.ClientID)
-
-	extensions, err := am.getUserExtensions()
-	if err != nil {
-		return err
-	}
-
-	// If the wt_account_id extension does not already exist, create it.
-	if !hasExtension(extensions, wtAccountIDField) {
-		_, err = am.createUserExtension(wtAccountID)
-		if err != nil {
-			return err
-		}
-	}
-
-	// If the wt_pending_invite extension does not already exist, create it.
-	if !hasExtension(extensions, wtPendingInviteField) {
-		_, err = am.createUserExtension(wtPendingInvite)
-		if err != nil {
-			return err
-		}
-	}
-
-	return nil
-}
-
 // get perform Get requests.
 func (am *AzureManager) get(resource string, q url.Values) ([]byte, error) {
 	jwtToken, err := am.credentials.Authenticate()
@@ -602,44 +409,8 @@ func (am *AzureManager) get(resource string, q url.Values) ([]byte, error) {
 	return io.ReadAll(resp.Body)
 }
 
-// post perform Post requests.
-func (am *AzureManager) post(resource string, body string) ([]byte, error) {
-	jwtToken, err := am.credentials.Authenticate()
-	if err != nil {
-		return nil, err
-	}
-
-	reqURL := fmt.Sprintf("%s/%s", am.GraphAPIEndpoint, resource)
-	req, err := http.NewRequest(http.MethodPost, reqURL, strings.NewReader(body))
-	if err != nil {
-		return nil, err
-	}
-	req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken)
-	req.Header.Add("content-type", "application/json")
-
-	resp, err := am.httpClient.Do(req)
-	if err != nil {
-		if am.appMetrics != nil {
-			am.appMetrics.IDPMetrics().CountRequestError()
-		}
-
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusCreated {
-		if am.appMetrics != nil {
-			am.appMetrics.IDPMetrics().CountRequestStatusError()
-		}
-
-		return nil, fmt.Errorf("unable to post %s, statusCode %d", reqURL, resp.StatusCode)
-	}
-
-	return io.ReadAll(resp.Body)
-}
-
 // userData construct user data from keycloak profile.
-func (ap azureProfile) userData(clientID string) *UserData {
+func (ap azureProfile) userData() *UserData {
 	id, ok := ap["id"].(string)
 	if !ok {
 		id = ""
@@ -655,66 +426,9 @@ func (ap azureProfile) userData(clientID string) *UserData {
 		name = ""
 	}
 
-	accountIDField := extensionName(wtAccountIDTpl, clientID)
-	accountID, ok := ap[accountIDField].(string)
-	if !ok {
-		accountID = ""
-	}
-
-	pendingInviteField := extensionName(wtPendingInviteTpl, clientID)
-	pendingInvite, ok := ap[pendingInviteField].(bool)
-	if !ok {
-		pendingInvite = false
-	}
-
 	return &UserData{
 		Email: email,
 		Name:  name,
 		ID:    id,
-		AppMetadata: AppMetadata{
-			WTAccountID:     accountID,
-			WTPendingInvite: &pendingInvite,
-		},
-	}
-}
-
-func buildAzureCreateUserRequestPayload(email, name, accountID, clientID string) (string, error) {
-	wtAccountIDField := extensionName(wtAccountIDTpl, clientID)
-	wtPendingInviteField := extensionName(wtPendingInviteTpl, clientID)
-
-	req := &azureProfile{
-		"accountEnabled":    true,
-		"displayName":       name,
-		"mailNickName":      strings.Join(strings.Split(name, " "), ""),
-		"userPrincipalName": email,
-		"passwordProfile": passwordProfile{
-			ForceChangePasswordNextSignIn: true,
-			Password:                      GeneratePassword(8, 1, 1, 1),
-		},
-		wtAccountIDField:     accountID,
-		wtPendingInviteField: true,
-	}
-
-	str, err := json.Marshal(req)
-	if err != nil {
-		return "", err
-	}
-
-	return string(str), nil
-}
-
-func extensionName(extensionTpl, clientID string) string {
-	clientID = strings.ReplaceAll(clientID, "-", "")
-	return fmt.Sprintf(extensionTpl, clientID)
-}
-
-// hasExtension checks whether a given extension by name,
-// exists in an list of extensions.
-func hasExtension(extensions []azureExtension, name string) bool {
-	for _, ext := range extensions {
-		if ext.Name == name {
-			return true
-		}
 	}
-	return false
 }
diff --git a/management/server/idp/google_workspace.go b/management/server/idp/google_workspace.go
index 40854e59..ed2de9a4 100644
--- a/management/server/idp/google_workspace.go
+++ b/management/server/idp/google_workspace.go
@@ -5,15 +5,14 @@ import (
 	"encoding/base64"
 	"fmt"
 	"net/http"
-	"strings"
 	"time"
 
-	"github.com/netbirdio/netbird/management/server/telemetry"
 	log "github.com/sirupsen/logrus"
 	"golang.org/x/oauth2/google"
 	admin "google.golang.org/api/admin/directory/v1"
-	"google.golang.org/api/googleapi"
 	"google.golang.org/api/option"
+
+	"github.com/netbirdio/netbird/management/server/telemetry"
 )
 
 // GoogleWorkspaceManager Google Workspace manager client instance.
@@ -73,17 +72,13 @@ func NewGoogleWorkspaceManager(config GoogleWorkspaceClientConfig, appMetrics te
 	}
 
 	service, err := admin.NewService(context.Background(),
-		option.WithScopes(admin.AdminDirectoryUserScope, admin.AdminDirectoryUserschemaScope),
+		option.WithScopes(admin.AdminDirectoryUserReadonlyScope),
 		option.WithCredentials(adminCredentials),
 	)
 	if err != nil {
 		return nil, err
 	}
 
-	if err = configureAppMetadataSchema(service, config.CustomerID); err != nil {
-		return nil, err
-	}
-
 	return &GoogleWorkspaceManager{
 		usersService: service.Users,
 		CustomerID:   config.CustomerID,
@@ -95,27 +90,7 @@ func NewGoogleWorkspaceManager(config GoogleWorkspaceClientConfig, appMetrics te
 }
 
 // UpdateUserAppMetadata updates user app metadata based on userID and metadata map.
-func (gm *GoogleWorkspaceManager) UpdateUserAppMetadata(userID string, appMetadata AppMetadata) error {
-	metadata, err := gm.helper.Marshal(appMetadata)
-	if err != nil {
-		return err
-	}
-
-	user := &admin.User{
-		CustomSchemas: map[string]googleapi.RawMessage{
-			"app_metadata": metadata,
-		},
-	}
-
-	_, err = gm.usersService.Update(userID, user).Do()
-	if err != nil {
-		return err
-	}
-
-	if gm.appMetrics != nil {
-		gm.appMetrics.IDPMetrics().CountUpdateUserAppMetadata()
-	}
-
+func (gm *GoogleWorkspaceManager) UpdateUserAppMetadata(_ string, _ AppMetadata) error {
 	return nil
 }
 
@@ -130,23 +105,23 @@ func (gm *GoogleWorkspaceManager) GetUserDataByID(userID string, appMetadata App
 		gm.appMetrics.IDPMetrics().CountGetUserDataByID()
 	}
 
-	return parseGoogleWorkspaceUser(user)
+	userData := parseGoogleWorkspaceUser(user)
+	userData.AppMetadata = appMetadata
+
+	return userData, nil
 }
 
 // GetAccount returns all the users for a given profile.
 func (gm *GoogleWorkspaceManager) GetAccount(accountID string) ([]*UserData, error) {
-	query := fmt.Sprintf("app_metadata.wt_account_id=\"%s\"", accountID)
-	usersList, err := gm.usersService.List().Customer(gm.CustomerID).Query(query).Projection("full").Do()
+	usersList, err := gm.usersService.List().Customer(gm.CustomerID).Projection("full").Do()
 	if err != nil {
 		return nil, err
 	}
 
 	usersData := make([]*UserData, 0)
 	for _, user := range usersList.Users {
-		userData, err := parseGoogleWorkspaceUser(user)
-		if err != nil {
-			return nil, err
-		}
+		userData := parseGoogleWorkspaceUser(user)
+		userData.AppMetadata.WTAccountID = accountID
 
 		usersData = append(usersData, userData)
 	}
@@ -168,61 +143,16 @@ func (gm *GoogleWorkspaceManager) GetAllAccounts() (map[string][]*UserData, erro
 
 	indexedUsers := make(map[string][]*UserData)
 	for _, user := range usersList.Users {
-		userData, err := parseGoogleWorkspaceUser(user)
-		if err != nil {
-			return nil, err
-		}
-
-		accountID := userData.AppMetadata.WTAccountID
-		if accountID != "" {
-			if _, ok := indexedUsers[accountID]; !ok {
-				indexedUsers[accountID] = make([]*UserData, 0)
-			}
-			indexedUsers[accountID] = append(indexedUsers[accountID], userData)
-		}
+		userData := parseGoogleWorkspaceUser(user)
+		indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], userData)
 	}
 
 	return indexedUsers, nil
 }
 
 // CreateUser creates a new user in Google Workspace and sends an invitation.
-func (gm *GoogleWorkspaceManager) CreateUser(email, name, accountID, invitedByEmail string) (*UserData, error) {
-	invite := true
-	metadata := AppMetadata{
-		WTAccountID:     accountID,
-		WTPendingInvite: &invite,
-	}
-
-	username := &admin.UserName{}
-	fields := strings.Fields(name)
-	if n := len(fields); n > 0 {
-		username.GivenName = strings.Join(fields[:n-1], " ")
-		username.FamilyName = fields[n-1]
-	}
-
-	payload, err := gm.helper.Marshal(metadata)
-	if err != nil {
-		return nil, err
-	}
-
-	user := &admin.User{
-		Name:         username,
-		PrimaryEmail: email,
-		CustomSchemas: map[string]googleapi.RawMessage{
-			"app_metadata": payload,
-		},
-		Password: GeneratePassword(8, 1, 1, 1),
-	}
-	user, err = gm.usersService.Insert(user).Do()
-	if err != nil {
-		return nil, err
-	}
-
-	if gm.appMetrics != nil {
-		gm.appMetrics.IDPMetrics().CountCreateUser()
-	}
-
-	return parseGoogleWorkspaceUser(user)
+func (gm *GoogleWorkspaceManager) CreateUser(_, _, _, _ string) (*UserData, error) {
+	return nil, fmt.Errorf("method CreateUser not implemented")
 }
 
 // GetUserByEmail searches users with a given email.
@@ -237,13 +167,8 @@ func (gm *GoogleWorkspaceManager) GetUserByEmail(email string) ([]*UserData, err
 		gm.appMetrics.IDPMetrics().CountGetUserByEmail()
 	}
 
-	userData, err := parseGoogleWorkspaceUser(user)
-	if err != nil {
-		return nil, err
-	}
-
 	users := make([]*UserData, 0)
-	users = append(users, userData)
+	users = append(users, parseGoogleWorkspaceUser(user))
 
 	return users, nil
 }
@@ -281,8 +206,7 @@ func getGoogleCredentials(serviceAccountKey string) (*google.Credentials, error)
 	creds, err := google.CredentialsFromJSON(
 		context.Background(),
 		decodeKey,
-		admin.AdminDirectoryUserschemaScope,
-		admin.AdminDirectoryUserScope,
+		admin.AdminDirectoryUserReadonlyScope,
 	)
 	if err == nil {
 		// No need to fallback to the default Google credentials path
@@ -294,8 +218,7 @@ func getGoogleCredentials(serviceAccountKey string) (*google.Credentials, error)
 
 	creds, err = google.FindDefaultCredentials(
 		context.Background(),
-		admin.AdminDirectoryUserschemaScope,
-		admin.AdminDirectoryUserScope,
+		admin.AdminDirectoryUserReadonlyScope,
 	)
 	if err != nil {
 		return nil, err
@@ -304,62 +227,11 @@ func getGoogleCredentials(serviceAccountKey string) (*google.Credentials, error)
 	return creds, nil
 }
 
-// configureAppMetadataSchema create a custom schema for managing app metadata fields in Google Workspace.
-func configureAppMetadataSchema(service *admin.Service, customerID string) error {
-	schemaList, err := service.Schemas.List(customerID).Do()
-	if err != nil {
-		return err
-	}
-
-	// checks if app_metadata schema is already created
-	for _, schema := range schemaList.Schemas {
-		if schema.SchemaName == "app_metadata" {
-			return nil
-		}
-	}
-
-	// create new app_metadata schema
-	appMetadataSchema := &admin.Schema{
-		SchemaName: "app_metadata",
-		Fields: []*admin.SchemaFieldSpec{
-			{
-				FieldName:   "wt_account_id",
-				FieldType:   "STRING",
-				MultiValued: false,
-			},
-			{
-				FieldName:   "wt_pending_invite",
-				FieldType:   "BOOL",
-				MultiValued: false,
-			},
-		},
-	}
-	_, err = service.Schemas.Insert(customerID, appMetadataSchema).Do()
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
-
 // parseGoogleWorkspaceUser parse google user to UserData.
-func parseGoogleWorkspaceUser(user *admin.User) (*UserData, error) {
-	var appMetadata AppMetadata
-
-	// Get app metadata from custom schemas
-	if user.CustomSchemas != nil {
-		rawMessage := user.CustomSchemas["app_metadata"]
-		helper := JsonParser{}
-
-		if err := helper.Unmarshal(rawMessage, &appMetadata); err != nil {
-			return nil, err
-		}
-	}
-
+func parseGoogleWorkspaceUser(user *admin.User) *UserData {
 	return &UserData{
-		ID:          user.Id,
-		Email:       user.PrimaryEmail,
-		Name:        user.Name.FullName,
-		AppMetadata: appMetadata,
-	}, nil
+		ID:    user.Id,
+		Email: user.PrimaryEmail,
+		Name:  user.Name.FullName,
+	}
 }
diff --git a/management/server/idp/idp.go b/management/server/idp/idp.go
index 1e2ac658..3b6a633c 100644
--- a/management/server/idp/idp.go
+++ b/management/server/idp/idp.go
@@ -9,6 +9,11 @@ import (
 	"github.com/netbirdio/netbird/management/server/telemetry"
 )
 
+const (
+	// UnsetAccountID is a special key to map users without an account ID
+	UnsetAccountID = "unset"
+)
+
 // Manager idp manager interface
 type Manager interface {
 	UpdateUserAppMetadata(userId string, appMetadata AppMetadata) error
diff --git a/management/server/idp/keycloak.go b/management/server/idp/keycloak.go
index d65a78ae..3a6f80d0 100644
--- a/management/server/idp/keycloak.go
+++ b/management/server/idp/keycloak.go
@@ -1,12 +1,10 @@
 package idp
 
 import (
-	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"net/url"
-	"path"
 	"strconv"
 	"strings"
 	"sync"
@@ -18,11 +16,6 @@ import (
 	"github.com/netbirdio/netbird/management/server/telemetry"
 )
 
-const (
-	wtAccountID     = "wt_account_id"
-	wtPendingInvite = "wt_pending_invite"
-)
-
 // KeycloakManager keycloak manager client instance.
 type KeycloakManager struct {
 	adminEndpoint string
@@ -51,28 +44,10 @@ type KeycloakCredentials struct {
 	appMetrics   telemetry.AppMetrics
 }
 
-// keycloakUserCredential describe the authentication method for,
-// newly created user profile.
-type keycloakUserCredential struct {
-	Type      string `json:"type"`
-	Value     string `json:"value"`
-	Temporary bool   `json:"temporary"`
-}
-
 // keycloakUserAttributes holds additional user data fields.
 type keycloakUserAttributes map[string][]string
 
-// createUserRequest is a user create request.
-type keycloakCreateUserRequest struct {
-	Email         string                   `json:"email"`
-	Username      string                   `json:"username"`
-	Enabled       bool                     `json:"enabled"`
-	EmailVerified bool                     `json:"emailVerified"`
-	Credentials   []keycloakUserCredential `json:"credentials"`
-	Attributes    keycloakUserAttributes   `json:"attributes"`
-}
-
-// keycloakProfile represents an keycloak user profile response.
+// keycloakProfile represents a keycloak user profile response.
 type keycloakProfile struct {
 	ID               string                 `json:"id"`
 	CreatedTimestamp int64                  `json:"createdTimestamp"`
@@ -230,62 +205,8 @@ func (kc *KeycloakCredentials) Authenticate() (JWTToken, error) {
 }
 
 // CreateUser creates a new user in keycloak Idp and sends an invite.
-func (km *KeycloakManager) CreateUser(email, name, accountID, invitedByEmail string) (*UserData, error) {
-	jwtToken, err := km.credentials.Authenticate()
-	if err != nil {
-		return nil, err
-	}
-
-	invite := true
-	appMetadata := AppMetadata{
-		WTAccountID:     accountID,
-		WTPendingInvite: &invite,
-	}
-
-	payloadString, err := buildKeycloakCreateUserRequestPayload(email, name, appMetadata)
-	if err != nil {
-		return nil, err
-	}
-
-	reqURL := fmt.Sprintf("%s/users", km.adminEndpoint)
-	payload := strings.NewReader(payloadString)
-
-	req, err := http.NewRequest(http.MethodPost, reqURL, payload)
-	if err != nil {
-		return nil, err
-	}
-	req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken)
-	req.Header.Add("content-type", "application/json")
-
-	if km.appMetrics != nil {
-		km.appMetrics.IDPMetrics().CountCreateUser()
-	}
-
-	resp, err := km.httpClient.Do(req)
-	if err != nil {
-		if km.appMetrics != nil {
-			km.appMetrics.IDPMetrics().CountRequestError()
-		}
-
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusCreated {
-		if km.appMetrics != nil {
-			km.appMetrics.IDPMetrics().CountRequestStatusError()
-		}
-
-		return nil, fmt.Errorf("unable to create user, statusCode %d", resp.StatusCode)
-	}
-
-	locationHeader := resp.Header.Get("location")
-	userID, err := extractUserIDFromLocationHeader(locationHeader)
-	if err != nil {
-		return nil, err
-	}
-
-	return km.GetUserDataByID(userID, appMetadata)
+func (km *KeycloakManager) CreateUser(_, _, _, _ string) (*UserData, error) {
+	return nil, fmt.Errorf("method CreateUser not implemented")
 }
 
 // GetUserByEmail searches users with a given email.
@@ -319,7 +240,7 @@ func (km *KeycloakManager) GetUserByEmail(email string) ([]*UserData, error) {
 }
 
 // GetUserDataByID requests user data from keycloak via ID.
-func (km *KeycloakManager) GetUserDataByID(userID string, appMetadata AppMetadata) (*UserData, error) {
+func (km *KeycloakManager) GetUserDataByID(userID string, _ AppMetadata) (*UserData, error) {
 	body, err := km.get("users/"+userID, nil)
 	if err != nil {
 		return nil, err
@@ -338,12 +259,9 @@ func (km *KeycloakManager) GetUserDataByID(userID string, appMetadata AppMetadat
 	return profile.userData(), nil
 }
 
-// GetAccount returns all the users for a given profile.
+// GetAccount returns all the users for a given account profile.
 func (km *KeycloakManager) GetAccount(accountID string) ([]*UserData, error) {
-	q := url.Values{}
-	q.Add("q", wtAccountID+":"+accountID)
-
-	body, err := km.get("users", q)
+	profiles, err := km.fetchAllUserProfiles()
 	if err != nil {
 		return nil, err
 	}
@@ -352,15 +270,12 @@ func (km *KeycloakManager) GetAccount(accountID string) ([]*UserData, error) {
 		km.appMetrics.IDPMetrics().CountGetAccount()
 	}
 
-	profiles := make([]keycloakProfile, 0)
-	err = km.helper.Unmarshal(body, &profiles)
-	if err != nil {
-		return nil, err
-	}
-
 	users := make([]*UserData, 0)
 	for _, profile := range profiles {
-		users = append(users, profile.userData())
+		userData := profile.userData()
+		userData.AppMetadata.WTAccountID = accountID
+
+		users = append(users, userData)
 	}
 
 	return users, nil
@@ -369,15 +284,7 @@ func (km *KeycloakManager) GetAccount(accountID string) ([]*UserData, error) {
 // GetAllAccounts gets all registered accounts with corresponding user data.
 // It returns a list of users indexed by accountID.
 func (km *KeycloakManager) GetAllAccounts() (map[string][]*UserData, error) {
-	totalUsers, err := km.totalUsersCount()
-	if err != nil {
-		return nil, err
-	}
-
-	q := url.Values{}
-	q.Add("max", fmt.Sprint(*totalUsers))
-
-	body, err := km.get("users", q)
+	profiles, err := km.fetchAllUserProfiles()
 	if err != nil {
 		return nil, err
 	}
@@ -386,78 +293,17 @@ func (km *KeycloakManager) GetAllAccounts() (map[string][]*UserData, error) {
 		km.appMetrics.IDPMetrics().CountGetAllAccounts()
 	}
 
-	profiles := make([]keycloakProfile, 0)
-	err = km.helper.Unmarshal(body, &profiles)
-	if err != nil {
-		return nil, err
-	}
-
 	indexedUsers := make(map[string][]*UserData)
 	for _, profile := range profiles {
 		userData := profile.userData()
-
-		accountID := userData.AppMetadata.WTAccountID
-		if accountID != "" {
-			if _, ok := indexedUsers[accountID]; !ok {
-				indexedUsers[accountID] = make([]*UserData, 0)
-			}
-			indexedUsers[accountID] = append(indexedUsers[accountID], userData)
-		}
+		indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], userData)
 	}
 
 	return indexedUsers, nil
 }
 
 // UpdateUserAppMetadata updates user app metadata based on userID and metadata map.
-func (km *KeycloakManager) UpdateUserAppMetadata(userID string, appMetadata AppMetadata) error {
-	jwtToken, err := km.credentials.Authenticate()
-	if err != nil {
-		return err
-	}
-
-	attrs := keycloakUserAttributes{}
-	attrs.Set(wtAccountID, appMetadata.WTAccountID)
-	if appMetadata.WTPendingInvite != nil {
-		attrs.Set(wtPendingInvite, strconv.FormatBool(*appMetadata.WTPendingInvite))
-	} else {
-		attrs.Set(wtPendingInvite, "false")
-	}
-
-	reqURL := fmt.Sprintf("%s/users/%s", km.adminEndpoint, userID)
-	data, err := km.helper.Marshal(map[string]any{
-		"attributes": attrs,
-	})
-	if err != nil {
-		return err
-	}
-	payload := strings.NewReader(string(data))
-
-	req, err := http.NewRequest(http.MethodPut, reqURL, payload)
-	if err != nil {
-		return err
-	}
-	req.Header.Add("authorization", "Bearer "+jwtToken.AccessToken)
-	req.Header.Add("content-type", "application/json")
-
-	log.Debugf("updating IdP metadata for user %s", userID)
-
-	resp, err := km.httpClient.Do(req)
-	if err != nil {
-		if km.appMetrics != nil {
-			km.appMetrics.IDPMetrics().CountRequestError()
-		}
-		return err
-	}
-	defer resp.Body.Close()
-
-	if km.appMetrics != nil {
-		km.appMetrics.IDPMetrics().CountUpdateUserAppMetadata()
-	}
-
-	if resp.StatusCode != http.StatusNoContent {
-		return fmt.Errorf("unable to update the appMetadata, statusCode %d", resp.StatusCode)
-	}
-
+func (km *KeycloakManager) UpdateUserAppMetadata(_ string, _ AppMetadata) error {
 	return nil
 }
 
@@ -467,7 +313,7 @@ func (km *KeycloakManager) InviteUserByID(_ string) error {
 	return fmt.Errorf("method InviteUserByID not implemented")
 }
 
-// DeleteUser from Keycloack
+// DeleteUser from Keycloak by user ID.
 func (km *KeycloakManager) DeleteUser(userID string) error {
 	jwtToken, err := km.credentials.Authenticate()
 	if err != nil {
@@ -475,7 +321,6 @@ func (km *KeycloakManager) DeleteUser(userID string) error {
 	}
 
 	reqURL := fmt.Sprintf("%s/users/%s", km.adminEndpoint, url.QueryEscape(userID))
-
 	req, err := http.NewRequest(http.MethodDelete, reqURL, nil)
 	if err != nil {
 		return err
@@ -508,32 +353,27 @@ func (km *KeycloakManager) DeleteUser(userID string) error {
 	return nil
 }
 
-func buildKeycloakCreateUserRequestPayload(email string, name string, appMetadata AppMetadata) (string, error) {
-	attrs := keycloakUserAttributes{}
-	attrs.Set(wtAccountID, appMetadata.WTAccountID)
-	attrs.Set(wtPendingInvite, strconv.FormatBool(*appMetadata.WTPendingInvite))
-
-	req := &keycloakCreateUserRequest{
-		Email:         email,
-		Username:      name,
-		Enabled:       true,
-		EmailVerified: true,
-		Credentials: []keycloakUserCredential{
-			{
-				Type:      "password",
-				Value:     GeneratePassword(8, 1, 1, 1),
-				Temporary: false,
-			},
-		},
-		Attributes: attrs,
-	}
-
-	str, err := json.Marshal(req)
+func (km *KeycloakManager) fetchAllUserProfiles() ([]keycloakProfile, error) {
+	totalUsers, err := km.totalUsersCount()
+	if err != nil {
+		return nil, err
+	}
+
+	q := url.Values{}
+	q.Add("max", fmt.Sprint(*totalUsers))
+
+	body, err := km.get("users", q)
 	if err != nil {
-		return "", err
+		return nil, err
 	}
 
-	return string(str), nil
+	profiles := make([]keycloakProfile, 0)
+	err = km.helper.Unmarshal(body, &profiles)
+	if err != nil {
+		return nil, err
+	}
+
+	return profiles, nil
 }
 
 // get perform Get requests.
@@ -588,53 +428,11 @@ func (km *KeycloakManager) totalUsersCount() (*int, error) {
 	return &count, nil
 }
 
-// extractUserIDFromLocationHeader extracts the user ID from the location,
-// header once the user is created successfully
-func extractUserIDFromLocationHeader(locationHeader string) (string, error) {
-	userURL, err := url.Parse(locationHeader)
-	if err != nil {
-		return "", err
-	}
-
-	return path.Base(userURL.Path), nil
-}
-
 // userData construct user data from keycloak profile.
 func (kp keycloakProfile) userData() *UserData {
-	accountID := kp.Attributes.Get(wtAccountID)
-	pendingInvite, err := strconv.ParseBool(kp.Attributes.Get(wtPendingInvite))
-	if err != nil {
-		pendingInvite = false
-	}
-
 	return &UserData{
 		Email: kp.Email,
 		Name:  kp.Username,
 		ID:    kp.ID,
-		AppMetadata: AppMetadata{
-			WTAccountID:     accountID,
-			WTPendingInvite: &pendingInvite,
-		},
-	}
-}
-
-// Set sets the key to value. It replaces any existing
-// values.
-func (ka keycloakUserAttributes) Set(key, value string) {
-	ka[key] = []string{value}
-}
-
-// Get returns the first value associated with the given key.
-// If there are no values associated with the key, Get returns
-// the empty string.
-func (ka keycloakUserAttributes) Get(key string) string {
-	if ka == nil {
-		return ""
-	}
-
-	values := ka[key]
-	if len(values) == 0 {
-		return ""
 	}
-	return values[0]
 }
diff --git a/management/server/idp/okta.go b/management/server/idp/okta.go
index 0e93c494..3e7b9357 100644
--- a/management/server/idp/okta.go
+++ b/management/server/idp/okta.go
@@ -8,9 +8,9 @@ import (
 	"strings"
 	"time"
 
-	"github.com/netbirdio/netbird/management/server/telemetry"
 	"github.com/okta/okta-sdk-golang/v2/okta"
-	"github.com/okta/okta-sdk-golang/v2/okta/query"
+
+	"github.com/netbirdio/netbird/management/server/telemetry"
 )
 
 // OktaManager okta manager client instance.
@@ -76,11 +76,6 @@ func NewOktaManager(config OktaClientConfig, appMetrics telemetry.AppMetrics) (*
 		return nil, err
 	}
 
-	err = updateUserProfileSchema(client)
-	if err != nil {
-		return nil, err
-	}
-
 	credentials := &OktaCredentials{
 		clientConfig: config,
 		httpClient:   httpClient,
@@ -103,49 +98,8 @@ func (oc *OktaCredentials) Authenticate() (JWTToken, error) {
 }
 
 // CreateUser creates a new user in okta Idp and sends an invitation.
-func (om *OktaManager) CreateUser(email, name, accountID, invitedByEmail string) (*UserData, error) {
-	var (
-		sendEmail   = true
-		activate    = true
-		userProfile = okta.UserProfile{
-			"email":         email,
-			"login":         email,
-			wtAccountID:     accountID,
-			wtPendingInvite: true,
-		}
-	)
-
-	fields := strings.Fields(name)
-	if n := len(fields); n > 0 {
-		userProfile["firstName"] = strings.Join(fields[:n-1], " ")
-		userProfile["lastName"] = fields[n-1]
-	}
-
-	user, resp, err := om.client.User.CreateUser(context.Background(),
-		okta.CreateUserRequest{
-			Profile: &userProfile,
-		},
-		&query.Params{
-			Activate:  &activate,
-			SendEmail: &sendEmail,
-		},
-	)
-	if err != nil {
-		return nil, err
-	}
-
-	if om.appMetrics != nil {
-		om.appMetrics.IDPMetrics().CountCreateUser()
-	}
-
-	if resp.StatusCode != http.StatusOK {
-		if om.appMetrics != nil {
-			om.appMetrics.IDPMetrics().CountRequestStatusError()
-		}
-		return nil, fmt.Errorf("unable to create user, statusCode %d", resp.StatusCode)
-	}
-
-	return parseOktaUser(user)
+func (om *OktaManager) CreateUser(_, _, _, _ string) (*UserData, error) {
+	return nil, fmt.Errorf("method CreateUser not implemented")
 }
 
 // GetUserDataByID requests user data from keycloak via ID.
@@ -166,7 +120,13 @@ func (om *OktaManager) GetUserDataByID(userID string, appMetadata AppMetadata) (
 		return nil, fmt.Errorf("unable to get user %s, statusCode %d", userID, resp.StatusCode)
 	}
 
-	return parseOktaUser(user)
+	userData, err := parseOktaUser(user)
+	if err != nil {
+		return nil, err
+	}
+	userData.AppMetadata = appMetadata
+
+	return userData, nil
 }
 
 // GetUserByEmail searches users with a given email.
@@ -200,8 +160,7 @@ func (om *OktaManager) GetUserByEmail(email string) ([]*UserData, error) {
 
 // GetAccount returns all the users for a given profile.
 func (om *OktaManager) GetAccount(accountID string) ([]*UserData, error) {
-	search := fmt.Sprintf("profile.wt_account_id eq %q", accountID)
-	users, resp, err := om.client.User.ListUsers(context.Background(), &query.Params{Search: search})
+	users, resp, err := om.client.User.ListUsers(context.Background(), nil)
 	if err != nil {
 		return nil, err
 	}
@@ -223,6 +182,7 @@ func (om *OktaManager) GetAccount(accountID string) ([]*UserData, error) {
 		if err != nil {
 			return nil, err
 		}
+		userData.AppMetadata.WTAccountID = accountID
 
 		list = append(list, userData)
 	}
@@ -256,13 +216,7 @@ func (om *OktaManager) GetAllAccounts() (map[string][]*UserData, error) {
 			return nil, err
 		}
 
-		accountID := userData.AppMetadata.WTAccountID
-		if accountID != "" {
-			if _, ok := indexedUsers[accountID]; !ok {
-				indexedUsers[accountID] = make([]*UserData, 0)
-			}
-			indexedUsers[accountID] = append(indexedUsers[accountID], userData)
-		}
+		indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], userData)
 	}
 
 	return indexedUsers, nil
@@ -270,46 +224,6 @@ func (om *OktaManager) GetAllAccounts() (map[string][]*UserData, error) {
 
 // UpdateUserAppMetadata updates user app metadata based on userID and metadata map.
 func (om *OktaManager) UpdateUserAppMetadata(userID string, appMetadata AppMetadata) error {
-	user, resp, err := om.client.User.GetUser(context.Background(), userID)
-	if err != nil {
-		return err
-	}
-
-	if resp.StatusCode != http.StatusOK {
-		if om.appMetrics != nil {
-			om.appMetrics.IDPMetrics().CountRequestStatusError()
-		}
-		return fmt.Errorf("unable to update user, statusCode %d", resp.StatusCode)
-	}
-
-	profile := *user.Profile
-
-	if appMetadata.WTPendingInvite != nil {
-		profile[wtPendingInvite] = *appMetadata.WTPendingInvite
-	}
-
-	if appMetadata.WTAccountID != "" {
-		profile[wtAccountID] = appMetadata.WTAccountID
-	}
-
-	user.Profile = &profile
-	_, resp, err = om.client.User.UpdateUser(context.Background(), userID, *user, nil)
-	if err != nil {
-		fmt.Println(err.Error())
-		return err
-	}
-
-	if om.appMetrics != nil {
-		om.appMetrics.IDPMetrics().CountUpdateUserAppMetadata()
-	}
-
-	if resp.StatusCode != http.StatusOK {
-		if om.appMetrics != nil {
-			om.appMetrics.IDPMetrics().CountRequestStatusError()
-		}
-		return fmt.Errorf("unable to update user, statusCode %d", resp.StatusCode)
-	}
-
 	return nil
 }
 
@@ -341,60 +255,12 @@ func (om *OktaManager) DeleteUser(userID string) error {
 	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 {
-	// Ensure Okta doesn't enforce user input for these fields, as they are solely used by Netbird
-	userPermissions := []*okta.UserSchemaAttributePermission{{Action: "HIDE", Principal: "SELF"}}
-
-	_, resp, err := client.UserSchema.UpdateUserProfile(
-		context.Background(),
-		"default",
-		okta.UserSchema{
-			Definitions: &okta.UserSchemaDefinitions{
-				Custom: &okta.UserSchemaPublic{
-					Id:   "#custom",
-					Type: "object",
-					Properties: map[string]*okta.UserSchemaAttribute{
-						wtAccountID: {
-							MaxLength:   100,
-							MinLength:   1,
-							Required:    new(bool),
-							Scope:       "NONE",
-							Title:       "Wt Account Id",
-							Type:        "string",
-							Permissions: userPermissions,
-						},
-						wtPendingInvite: {
-							Required:    new(bool),
-							Scope:       "NONE",
-							Title:       "Wt Pending Invite",
-							Type:        "boolean",
-							Permissions: userPermissions,
-						},
-					},
-				},
-			},
-		})
-	if err != nil {
-		return err
-	}
-
-	if resp.StatusCode != http.StatusOK {
-		return fmt.Errorf("unable to update user profile schema, statusCode %d", resp.StatusCode)
-	}
-
-	return nil
-}
-
 // parseOktaUserToUserData parse okta user to UserData.
 func parseOktaUser(user *okta.User) (*UserData, error) {
 	var oktaUser struct {
-		Email         string `json:"email"`
-		FirstName     string `json:"firstName"`
-		LastName      string `json:"lastName"`
-		AccountID     string `json:"wt_account_id"`
-		PendingInvite bool   `json:"wt_pending_invite"`
+		Email     string `json:"email"`
+		FirstName string `json:"firstName"`
+		LastName  string `json:"lastName"`
 	}
 
 	if user == nil {
@@ -418,9 +284,5 @@ func parseOktaUser(user *okta.User) (*UserData, error) {
 		Email: oktaUser.Email,
 		Name:  strings.Join([]string{oktaUser.FirstName, oktaUser.LastName}, " "),
 		ID:    user.Id,
-		AppMetadata: AppMetadata{
-			WTAccountID:     oktaUser.AccountID,
-			WTPendingInvite: &oktaUser.PendingInvite,
-		},
 	}, nil
 }
diff --git a/management/server/idp/zitadel.go b/management/server/idp/zitadel.go
index e42b9a50..5325e51b 100644
--- a/management/server/idp/zitadel.go
+++ b/management/server/idp/zitadel.go
@@ -1,13 +1,10 @@
 package idp
 
 import (
-	"encoding/base64"
-	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
 	"net/url"
-	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -68,12 +65,6 @@ type zitadelUser struct {
 
 type zitadelAttributes map[string][]map[string]any
 
-// zitadelMetadata holds additional user data.
-type zitadelMetadata struct {
-	Key   string `json:"key"`
-	Value string `json:"value"`
-}
-
 // zitadelProfile represents an zitadel user profile response.
 type zitadelProfile struct {
 	ID                 string       `json:"id"`
@@ -82,7 +73,6 @@ type zitadelProfile struct {
 	PreferredLoginName string       `json:"preferredLoginName"`
 	LoginNames         []string     `json:"loginNames"`
 	Human              *zitadelUser `json:"human"`
-	Metadata           []zitadelMetadata
 }
 
 // NewZitadelManager creates a new instance of the ZitadelManager.
@@ -235,42 +225,8 @@ func (zc *ZitadelCredentials) Authenticate() (JWTToken, error) {
 }
 
 // CreateUser creates a new user in zitadel Idp and sends an invite.
-func (zm *ZitadelManager) CreateUser(email, name, accountID, invitedByEmail string) (*UserData, error) {
-	payload, err := buildZitadelCreateUserRequestPayload(email, name)
-	if err != nil {
-		return nil, err
-	}
-
-	body, err := zm.post("users/human/_import", payload)
-	if err != nil {
-		return nil, err
-	}
-
-	if zm.appMetrics != nil {
-		zm.appMetrics.IDPMetrics().CountCreateUser()
-	}
-
-	var result struct {
-		UserID string `json:"userId"`
-	}
-	err = zm.helper.Unmarshal(body, &result)
-	if err != nil {
-		return nil, err
-	}
-
-	invite := true
-	appMetadata := AppMetadata{
-		WTAccountID:     accountID,
-		WTPendingInvite: &invite,
-	}
-
-	// Add metadata to new user
-	err = zm.UpdateUserAppMetadata(result.UserID, appMetadata)
-	if err != nil {
-		return nil, err
-	}
-
-	return zm.GetUserDataByID(result.UserID, appMetadata)
+func (zm *ZitadelManager) CreateUser(_, _, _, _ string) (*UserData, error) {
+	return nil, fmt.Errorf("method CreateUser not implemented")
 }
 
 // GetUserByEmail searches users with a given email.
@@ -308,12 +264,6 @@ func (zm *ZitadelManager) GetUserByEmail(email string) ([]*UserData, error) {
 
 	users := make([]*UserData, 0)
 	for _, profile := range profiles.Result {
-		metadata, err := zm.getUserMetadata(profile.ID)
-		if err != nil {
-			return nil, err
-		}
-		profile.Metadata = metadata
-
 		users = append(users, profile.userData())
 	}
 
@@ -337,18 +287,15 @@ func (zm *ZitadelManager) GetUserDataByID(userID string, appMetadata AppMetadata
 		return nil, err
 	}
 
-	metadata, err := zm.getUserMetadata(userID)
-	if err != nil {
-		return nil, err
-	}
-	profile.User.Metadata = metadata
+	userData := profile.User.userData()
+	userData.AppMetadata = appMetadata
 
-	return profile.User.userData(), nil
+	return userData, nil
 }
 
 // GetAccount returns all the users for a given profile.
 func (zm *ZitadelManager) GetAccount(accountID string) ([]*UserData, error) {
-	accounts, err := zm.GetAllAccounts()
+	body, err := zm.post("users/_search", "")
 	if err != nil {
 		return nil, err
 	}
@@ -357,7 +304,21 @@ func (zm *ZitadelManager) GetAccount(accountID string) ([]*UserData, error) {
 		zm.appMetrics.IDPMetrics().CountGetAccount()
 	}
 
-	return accounts[accountID], nil
+	var profiles struct{ Result []zitadelProfile }
+	err = zm.helper.Unmarshal(body, &profiles)
+	if err != nil {
+		return nil, err
+	}
+
+	users := make([]*UserData, 0)
+	for _, profile := range profiles.Result {
+		userData := profile.userData()
+		userData.AppMetadata.WTAccountID = accountID
+
+		users = append(users, userData)
+	}
+
+	return users, nil
 }
 
 // GetAllAccounts gets all registered accounts with corresponding user data.
@@ -380,22 +341,8 @@ func (zm *ZitadelManager) GetAllAccounts() (map[string][]*UserData, error) {
 
 	indexedUsers := make(map[string][]*UserData)
 	for _, profile := range profiles.Result {
-		// fetch user metadata
-		metadata, err := zm.getUserMetadata(profile.ID)
-		if err != nil {
-			return nil, err
-		}
-		profile.Metadata = metadata
-
 		userData := profile.userData()
-		accountID := userData.AppMetadata.WTAccountID
-
-		if accountID != "" {
-			if _, ok := indexedUsers[accountID]; !ok {
-				indexedUsers[accountID] = make([]*UserData, 0)
-			}
-			indexedUsers[accountID] = append(indexedUsers[accountID], userData)
-		}
+		indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], userData)
 	}
 
 	return indexedUsers, nil
@@ -403,42 +350,7 @@ func (zm *ZitadelManager) GetAllAccounts() (map[string][]*UserData, error) {
 
 // UpdateUserAppMetadata updates user app metadata based on userID and metadata map.
 // Metadata values are base64 encoded.
-func (zm *ZitadelManager) UpdateUserAppMetadata(userID string, appMetadata AppMetadata) error {
-	if appMetadata.WTPendingInvite == nil {
-		appMetadata.WTPendingInvite = new(bool)
-	}
-	pendingInviteBuf := strconv.AppendBool([]byte{}, *appMetadata.WTPendingInvite)
-
-	wtAccountIDValue := base64.StdEncoding.EncodeToString([]byte(appMetadata.WTAccountID))
-	wtPendingInviteValue := base64.StdEncoding.EncodeToString(pendingInviteBuf)
-
-	metadata := zitadelAttributes{
-		"metadata": {
-			{
-				"key":   wtAccountID,
-				"value": wtAccountIDValue,
-			},
-			{
-				"key":   wtPendingInvite,
-				"value": wtPendingInviteValue,
-			},
-		},
-	}
-	payload, err := zm.helper.Marshal(metadata)
-	if err != nil {
-		return err
-	}
-
-	resource := fmt.Sprintf("users/%s/metadata/_bulk", userID)
-	_, err = zm.post(resource, string(payload))
-	if err != nil {
-		return err
-	}
-
-	if zm.appMetrics != nil {
-		zm.appMetrics.IDPMetrics().CountUpdateUserAppMetadata()
-	}
-
+func (zm *ZitadelManager) UpdateUserAppMetadata(_ string, _ AppMetadata) error {
 	return nil
 }
 
@@ -460,24 +372,6 @@ func (zm *ZitadelManager) DeleteUser(userID string) error {
 	}
 
 	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)
-	body, err := zm.post(resource, "")
-	if err != nil {
-		return nil, err
-	}
-
-	var metadata struct{ Result []zitadelMetadata }
-	err = zm.helper.Unmarshal(body, &metadata)
-	if err != nil {
-		return nil, err
-	}
-
-	return metadata.Result, nil
 }
 
 // post perform Post requests.
@@ -517,38 +411,7 @@ func (zm *ZitadelManager) post(resource string, body string) ([]byte, error) {
 }
 
 // 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)
-	}
-
+func (zm *ZitadelManager) delete(_ string) error {
 	return nil
 }
 
@@ -588,38 +451,13 @@ func (zm *ZitadelManager) get(resource string, q url.Values) ([]byte, error) {
 	return io.ReadAll(resp.Body)
 }
 
-// value returns string represented by the base64 string value.
-func (zm zitadelMetadata) value() string {
-	value, err := base64.StdEncoding.DecodeString(zm.Value)
-	if err != nil {
-		return ""
-	}
-
-	return string(value)
-}
-
 // userData construct user data from zitadel profile.
 func (zp zitadelProfile) userData() *UserData {
 	var (
-		email                string
-		name                 string
-		wtAccountIDValue     string
-		wtPendingInviteValue bool
+		email string
+		name  string
 	)
 
-	for _, metadata := range zp.Metadata {
-		if metadata.Key == wtAccountID {
-			wtAccountIDValue = metadata.value()
-		}
-
-		if metadata.Key == wtPendingInvite {
-			value, err := strconv.ParseBool(metadata.value())
-			if err == nil {
-				wtPendingInviteValue = value
-			}
-		}
-	}
-
 	// Obtain the email for the human account and the login name,
 	// for the machine account.
 	if zp.Human != nil {
@@ -636,39 +474,5 @@ func (zp zitadelProfile) userData() *UserData {
 		Email: email,
 		Name:  name,
 		ID:    zp.ID,
-		AppMetadata: AppMetadata{
-			WTAccountID:     wtAccountIDValue,
-			WTPendingInvite: &wtPendingInviteValue,
-		},
-	}
-}
-
-func buildZitadelCreateUserRequestPayload(email string, name string) (string, error) {
-	var firstName, lastName string
-
-	words := strings.Fields(name)
-	if n := len(words); n > 0 {
-		firstName = strings.Join(words[:n-1], " ")
-		lastName = words[n-1]
 	}
-
-	req := &zitadelUser{
-		UserName: name,
-		Profile: zitadelUserInfo{
-			FirstName:   strings.TrimSpace(firstName),
-			LastName:    strings.TrimSpace(lastName),
-			DisplayName: name,
-		},
-		Email: zitadelEmail{
-			Email:           email,
-			IsEmailVerified: false,
-		},
-	}
-
-	str, err := json.Marshal(req)
-	if err != nil {
-		return "", err
-	}
-
-	return string(str), nil
 }
diff --git a/management/server/metrics/selfhosted.go b/management/server/metrics/selfhosted.go
index 696df5f3..3b3db0ba 100644
--- a/management/server/metrics/selfhosted.go
+++ b/management/server/metrics/selfhosted.go
@@ -176,6 +176,7 @@ func (w *Worker) generateProperties() properties {
 		rulesDirection        map[string]int
 		groups                int
 		routes                int
+		routesWithRGGroups    int
 		nameservers           int
 		uiClient              int
 		version               string
@@ -201,6 +202,11 @@ func (w *Worker) generateProperties() properties {
 
 		groups = groups + len(account.Groups)
 		routes = routes + len(account.Routes)
+		for _, route := range account.Routes {
+			if len(route.PeerGroups) > 0 {
+				routesWithRGGroups++
+			}
+		}
 		nameservers = nameservers + len(account.NameServerGroups)
 
 		for _, policy := range account.Policies {
@@ -282,6 +288,7 @@ func (w *Worker) generateProperties() properties {
 	metricsProperties["rules"] = rules
 	metricsProperties["groups"] = groups
 	metricsProperties["routes"] = routes
+	metricsProperties["routes_with_routing_groups"] = routesWithRGGroups
 	metricsProperties["nameservers"] = nameservers
 	metricsProperties["version"] = version
 	metricsProperties["min_active_peer_version"] = minActivePeerVersion
diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go
index 24bf9f3c..889bbb22 100644
--- a/management/server/mock_server/account_mock.go
+++ b/management/server/mock_server/account_mock.go
@@ -24,7 +24,7 @@ type MockAccountManager struct {
 	GetPeerByKeyFunc                func(peerKey string) (*server.Peer, error)
 	GetPeersFunc                    func(accountID, userID string) ([]*server.Peer, error)
 	MarkPeerConnectedFunc           func(peerKey string, connected bool) error
-	DeletePeerFunc                  func(accountID, peerKey, userID string) (*server.Peer, error)
+	DeletePeerFunc                  func(accountID, peerKey, userID string) error
 	GetPeerByIPFunc                 func(accountId string, peerIP string) (*server.Peer, error)
 	GetNetworkMapFunc               func(peerKey string) (*server.NetworkMap, error)
 	GetPeerNetworkFunc              func(peerKey string) (*server.Network, error)
@@ -33,8 +33,8 @@ type MockAccountManager struct {
 	SaveGroupFunc                   func(accountID, userID string, group *server.Group) error
 	DeleteGroupFunc                 func(accountID, userId, groupID string) error
 	ListGroupsFunc                  func(accountID string) ([]*server.Group, error)
-	GroupAddPeerFunc                func(accountID, groupID, peerKey string) error
-	GroupDeletePeerFunc             func(accountID, groupID, peerKey string) error
+	GroupAddPeerFunc                func(accountID, groupID, peerID string) error
+	GroupDeletePeerFunc             func(accountID, groupID, peerID string) error
 	GroupListPeersFunc              func(accountID, groupID string) ([]*server.Peer, error)
 	GetRuleFunc                     func(accountID, ruleID, userID string) (*server.Rule, error)
 	SaveRuleFunc                    func(accountID, userID string, rule *server.Rule) error
@@ -50,7 +50,7 @@ type MockAccountManager struct {
 	UpdatePeerMetaFunc              func(peerID string, meta server.PeerSystemMeta) error
 	UpdatePeerSSHKeyFunc            func(peerID string, sshKey string) error
 	UpdatePeerFunc                  func(accountID, userID string, peer *server.Peer) (*server.Peer, error)
-	CreateRouteFunc                 func(accountID string, prefix, peer, description, netID string, masquerade bool, metric int, groups []string, enabled bool, userID string) (*route.Route, error)
+	CreateRouteFunc                 func(accountID, prefix, peer string, peerGroups []string, description, netID string, masquerade bool, metric int, groups []string, enabled bool, userID string) (*route.Route, error)
 	GetRouteFunc                    func(accountID, routeID, userID string) (*route.Route, error)
 	SaveRouteFunc                   func(accountID, userID string, route *route.Route) error
 	DeleteRouteFunc                 func(accountID, routeID, userID string) error
@@ -90,11 +90,11 @@ func (am *MockAccountManager) GetUsersFromAccount(accountID string, userID strin
 }
 
 // DeletePeer mock implementation of DeletePeer from server.AccountManager interface
-func (am *MockAccountManager) DeletePeer(accountID, peerID, userID string) (*server.Peer, error) {
+func (am *MockAccountManager) DeletePeer(accountID, peerID, userID string) error {
 	if am.DeletePeerFunc != nil {
 		return am.DeletePeerFunc(accountID, peerID, userID)
 	}
-	return nil, status.Errorf(codes.Unimplemented, "method DeletePeer is not implemented")
+	return status.Errorf(codes.Unimplemented, "method DeletePeer is not implemented")
 }
 
 // GetOrCreateAccountByUser mock implementation of GetOrCreateAccountByUser from server.AccountManager interface
@@ -281,17 +281,17 @@ func (am *MockAccountManager) ListGroups(accountID string) ([]*server.Group, err
 }
 
 // GroupAddPeer mock implementation of GroupAddPeer from server.AccountManager interface
-func (am *MockAccountManager) GroupAddPeer(accountID, groupID, peerKey string) error {
+func (am *MockAccountManager) GroupAddPeer(accountID, groupID, peerID string) error {
 	if am.GroupAddPeerFunc != nil {
-		return am.GroupAddPeerFunc(accountID, groupID, peerKey)
+		return am.GroupAddPeerFunc(accountID, groupID, peerID)
 	}
 	return status.Errorf(codes.Unimplemented, "method GroupAddPeer is not implemented")
 }
 
 // GroupDeletePeer mock implementation of GroupDeletePeer from server.AccountManager interface
-func (am *MockAccountManager) GroupDeletePeer(accountID, groupID, peerKey string) error {
+func (am *MockAccountManager) GroupDeletePeer(accountID, groupID, peerID string) error {
 	if am.GroupDeletePeerFunc != nil {
-		return am.GroupDeletePeerFunc(accountID, groupID, peerKey)
+		return am.GroupDeletePeerFunc(accountID, groupID, peerID)
 	}
 	return status.Errorf(codes.Unimplemented, "method GroupDeletePeer is not implemented")
 }
@@ -401,9 +401,9 @@ func (am *MockAccountManager) UpdatePeer(accountID, userID string, peer *server.
 }
 
 // CreateRoute mock implementation of CreateRoute from server.AccountManager interface
-func (am *MockAccountManager) CreateRoute(accountID string, network, peerID, description, netID string, masquerade bool, metric int, groups []string, enabled bool, userID string) (*route.Route, error) {
+func (am *MockAccountManager) CreateRoute(accountID, network, peerID string, peerGroups []string, description, netID string, masquerade bool, metric int, groups []string, enabled bool, userID string) (*route.Route, error) {
 	if am.CreateRouteFunc != nil {
-		return am.CreateRouteFunc(accountID, network, peerID, description, netID, masquerade, metric, groups, enabled, userID)
+		return am.CreateRouteFunc(accountID, network, peerID, peerGroups, description, netID, masquerade, metric, groups, enabled, userID)
 	}
 	return nil, status.Errorf(codes.Unimplemented, "method CreateRoute is not implemented")
 }
diff --git a/management/server/peer.go b/management/server/peer.go
index f9631719..31ebd7cd 100644
--- a/management/server/peer.go
+++ b/management/server/peer.go
@@ -372,55 +372,79 @@ func (am *DefaultAccountManager) UpdatePeer(accountID, userID string, update *Pe
 	return peer, nil
 }
 
-// DeletePeer removes peer from the account by its IP
-func (am *DefaultAccountManager) DeletePeer(accountID, peerID, userID string) (*Peer, error) {
-	unlock := am.Store.AcquireAccountLock(accountID)
-	defer unlock()
+// deletePeers will delete all specified peers and send updates to the remote peers. Don't call without acquiring account lock
+func (am *DefaultAccountManager) deletePeers(account *Account, peerIDs []string, userID string) error {
 
-	account, err := am.Store.GetAccount(accountID)
-	if err != nil {
-		return nil, err
-	}
+	var peerError error
+	for _, peerID := range peerIDs {
 
-	peer := account.GetPeer(peerID)
-	if peer == nil {
-		return nil, status.Errorf(status.NotFound, "peer %s not found", peerID)
-	}
+		peer := account.GetPeer(peerID)
+		if peer == nil {
+			peerError = status.Errorf(status.NotFound, "peer %s not found", peerID)
+			goto save
+		}
 
-	account.DeletePeer(peerID)
+		account.DeletePeer(peerID)
+
+		peerError = am.peersUpdateManager.SendUpdate(peer.ID,
+			&UpdateMessage{
+				Update: &proto.SyncResponse{
+					// fill those field for backward compatibility
+					RemotePeers:        []*proto.RemotePeerConfig{},
+					RemotePeersIsEmpty: true,
+					// new field
+					NetworkMap: &proto.NetworkMap{
+						Serial:               account.Network.CurrentSerial(),
+						RemotePeers:          []*proto.RemotePeerConfig{},
+						RemotePeersIsEmpty:   true,
+						FirewallRules:        []*proto.FirewallRule{},
+						FirewallRulesIsEmpty: true,
+					},
+				},
+			})
+		if peerError != nil {
+			goto save
+		}
 
-	err = am.Store.SaveAccount(account)
+		am.peersUpdateManager.CloseChannel(peerID)
+		am.storeEvent(userID, peer.ID, account.Id, activity.PeerRemovedByUser, peer.EventMeta(am.GetDNSDomain()))
+	}
+
+save:
+	err := am.Store.SaveAccount(account)
 	if err != nil {
-		return nil, err
+		if peerError != nil {
+			log.Errorf("account save error: %s", err)
+			return peerError
+		} else {
+			return err
+		}
 	}
 
-	err = am.peersUpdateManager.SendUpdate(peer.ID,
-		&UpdateMessage{
-			Update: &proto.SyncResponse{
-				// fill those field for backward compatibility
-				RemotePeers:        []*proto.RemotePeerConfig{},
-				RemotePeersIsEmpty: true,
-				// new field
-				NetworkMap: &proto.NetworkMap{
-					Serial:               account.Network.CurrentSerial(),
-					RemotePeers:          []*proto.RemotePeerConfig{},
-					RemotePeersIsEmpty:   true,
-					FirewallRules:        []*proto.FirewallRule{},
-					FirewallRulesIsEmpty: true,
-				},
-			},
-		})
+	err = am.updateAccountPeers(account)
 	if err != nil {
-		return nil, err
+		if peerError != nil {
+			log.Errorf("update account peers error: %s", err)
+			return peerError
+		} else {
+			return err
+		}
 	}
 
-	if err := am.updateAccountPeers(account); err != nil {
-		return nil, err
+	return peerError
+}
+
+// DeletePeer removes peer from the account by its IP
+func (am *DefaultAccountManager) DeletePeer(accountID, peerID, userID string) error {
+	unlock := am.Store.AcquireAccountLock(accountID)
+	defer unlock()
+
+	account, err := am.Store.GetAccount(accountID)
+	if err != nil {
+		return err
 	}
 
-	am.peersUpdateManager.CloseChannel(peerID)
-	am.storeEvent(userID, peer.ID, account.Id, activity.PeerRemovedByUser, peer.EventMeta(am.GetDNSDomain()))
-	return peer, nil
+	return am.deletePeers(account, []string{peerID}, userID)
 }
 
 // GetPeerByIP returns peer by its IP
diff --git a/management/server/route.go b/management/server/route.go
index b232c2bb..cdd21420 100644
--- a/management/server/route.go
+++ b/management/server/route.go
@@ -39,30 +39,82 @@ func (am *DefaultAccountManager) GetRoute(accountID, routeID, userID string) (*r
 	return nil, status.Errorf(status.NotFound, "route with ID %s not found", routeID)
 }
 
-// checkPrefixPeerExists checks the combination of prefix and peer id, if it exists returns an error, otherwise returns nil
-func (am *DefaultAccountManager) checkPrefixPeerExists(accountID, peerID string, prefix netip.Prefix) error {
+// checkRoutePrefixExistsForPeers checks if a route with a given prefix exists for a single peer or multiple peer groups.
+func (am *DefaultAccountManager) checkRoutePrefixExistsForPeers(account *Account, peerID, routeID string, peerGroupIDs []string, prefix netip.Prefix) error {
+	// routes can have both peer and peer_groups
+	routesWithPrefix := account.GetRoutesByPrefix(prefix)
+
+	// lets remember all the peers and the peer groups from routesWithPrefix
+	seenPeers := make(map[string]bool)
+	seenPeerGroups := make(map[string]bool)
+
+	for _, prefixRoute := range routesWithPrefix {
+		// we skip route(s) with the same network ID as we want to allow updating of the existing route
+		// when create a new route routeID is newly generated so nothing will be skipped
+		if routeID == prefixRoute.ID {
+			continue
+		}
 
-	if peerID == "" {
-		return nil
+		if prefixRoute.Peer != "" {
+			seenPeers[prefixRoute.ID] = true
+		}
+		for _, groupID := range prefixRoute.PeerGroups {
+			seenPeerGroups[groupID] = true
+
+			group := account.GetGroup(groupID)
+			if group == nil {
+				return status.Errorf(
+					status.InvalidArgument, "failed to add route with prefix %s - peer group %s doesn't exist",
+					prefix.String(), groupID)
+			}
+
+			for _, pID := range group.Peers {
+				seenPeers[pID] = true
+			}
+		}
 	}
 
-	account, err := am.Store.GetAccount(accountID)
-	if err != nil {
-		return err
+	if peerID != "" {
+		// check that peerID exists and is not in any route as single peer or part of the group
+		peer := account.GetPeer(peerID)
+		if peer == nil {
+			return status.Errorf(status.InvalidArgument, "peer with ID %s not found", peerID)
+		}
+		if _, ok := seenPeers[peerID]; ok {
+			return status.Errorf(status.AlreadyExists,
+				"failed to add route with prefix %s - peer %s already has this route", prefix.String(), peerID)
+		}
 	}
 
-	routesWithPrefix := account.GetRoutesByPrefix(prefix)
+	// check that peerGroupIDs are not in any route peerGroups list
+	for _, groupID := range peerGroupIDs {
+		group := account.GetGroup(groupID) // we validated the group existent before entering this function, o need to check again.
 
-	for _, prefixRoute := range routesWithPrefix {
-		if prefixRoute.Peer == peerID {
-			return status.Errorf(status.AlreadyExists, "failed to add route with prefix %s - peer already has this route", prefix.String())
+		if _, ok := seenPeerGroups[groupID]; ok {
+			return status.Errorf(
+				status.AlreadyExists, "failed to add route with prefix %s - peer group %s already has this route",
+				prefix.String(), group.Name)
+		}
+
+		// check that the peers from peerGroupIDs groups are not the same peers we saw in routesWithPrefix
+		for _, id := range group.Peers {
+			if _, ok := seenPeers[id]; ok {
+				peer := account.GetPeer(peerID)
+				if peer == nil {
+					return status.Errorf(status.InvalidArgument, "peer with ID %s not found", peerID)
+				}
+				return status.Errorf(status.AlreadyExists,
+					"failed to add route with prefix %s - peer %s from the group %s already has this route",
+					prefix.String(), peer.Name, group.Name)
+			}
 		}
 	}
+
 	return nil
 }
 
 // CreateRoute creates and saves a new route
-func (am *DefaultAccountManager) CreateRoute(accountID string, network, peerID, description, netID string, masquerade bool, metric int, groups []string, enabled bool, userID string) (*route.Route, error) {
+func (am *DefaultAccountManager) CreateRoute(accountID, network, peerID string, peerGroupIDs []string, description, netID string, masquerade bool, metric int, groups []string, enabled bool, userID string) (*route.Route, error) {
 	unlock := am.Store.AcquireAccountLock(accountID)
 	defer unlock()
 
@@ -71,19 +123,29 @@ func (am *DefaultAccountManager) CreateRoute(accountID string, network, peerID,
 		return nil, err
 	}
 
-	if peerID != "" {
-		peer := account.GetPeer(peerID)
-		if peer == nil {
-			return nil, status.Errorf(status.InvalidArgument, "peer with ID %s not found", peerID)
-		}
+	if peerID != "" && len(peerGroupIDs) != 0 {
+		return nil, status.Errorf(
+			status.InvalidArgument,
+			"peer with ID %s and peers group %s should not be provided at the same time",
+			peerID, peerGroupIDs)
 	}
 
 	var newRoute route.Route
+	newRoute.ID = xid.New().String()
+
 	prefixType, newPrefix, err := route.ParseNetwork(network)
 	if err != nil {
 		return nil, status.Errorf(status.InvalidArgument, "failed to parse IP %s", network)
 	}
-	err = am.checkPrefixPeerExists(accountID, peerID, newPrefix)
+
+	if len(peerGroupIDs) > 0 {
+		err = validateGroups(peerGroupIDs, account.Groups)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	err = am.checkRoutePrefixExistsForPeers(account, peerID, newRoute.ID, peerGroupIDs, newPrefix)
 	if err != nil {
 		return nil, err
 	}
@@ -102,7 +164,7 @@ func (am *DefaultAccountManager) CreateRoute(accountID string, network, peerID,
 	}
 
 	newRoute.Peer = peerID
-	newRoute.ID = xid.New().String()
+	newRoute.PeerGroups = peerGroupIDs
 	newRoute.Network = newPrefix
 	newRoute.NetworkType = prefixType
 	newRoute.Description = description
@@ -160,13 +222,22 @@ func (am *DefaultAccountManager) SaveRoute(accountID, userID string, routeToSave
 		return err
 	}
 
-	if routeToSave.Peer != "" {
-		peer := account.GetPeer(routeToSave.Peer)
-		if peer == nil {
-			return status.Errorf(status.InvalidArgument, "peer with ID %s not found", routeToSave.Peer)
+	if routeToSave.Peer != "" && len(routeToSave.PeerGroups) != 0 {
+		return status.Errorf(status.InvalidArgument, "peer with ID and peer groups should not be provided at the same time")
+	}
+
+	if len(routeToSave.PeerGroups) > 0 {
+		err = validateGroups(routeToSave.PeerGroups, account.Groups)
+		if err != nil {
+			return err
 		}
 	}
 
+	err = am.checkRoutePrefixExistsForPeers(account, routeToSave.Peer, routeToSave.ID, routeToSave.Copy().PeerGroups, routeToSave.Network)
+	if err != nil {
+		return err
+	}
+
 	err = validateGroups(routeToSave.Groups, account.Groups)
 	if err != nil {
 		return err
diff --git a/management/server/telemetry/store_metrics.go b/management/server/telemetry/store_metrics.go
index 704ef65d..98c13f12 100644
--- a/management/server/telemetry/store_metrics.go
+++ b/management/server/telemetry/store_metrics.go
@@ -11,37 +11,54 @@ import (
 
 // StoreMetrics represents all metrics related to the FileStore
 type StoreMetrics struct {
-	globalLockAcquisitionDuration syncint64.Histogram
-	persistenceDuration           syncint64.Histogram
-	ctx                           context.Context
+	globalLockAcquisitionDurationMicro syncint64.Histogram
+	globalLockAcquisitionDurationMs    syncint64.Histogram
+	persistenceDurationMicro           syncint64.Histogram
+	persistenceDurationMs              syncint64.Histogram
+	ctx                                context.Context
 }
 
 // NewStoreMetrics creates an instance of StoreMetrics
 func NewStoreMetrics(ctx context.Context, meter metric.Meter) (*StoreMetrics, error) {
-	globalLockAcquisitionDuration, err := meter.SyncInt64().Histogram("management.store.global.lock.acquisition.duration.micro",
+	globalLockAcquisitionDurationMicro, err := meter.SyncInt64().Histogram("management.store.global.lock.acquisition.duration.micro",
 		instrument.WithUnit("microseconds"))
 	if err != nil {
 		return nil, err
 	}
-	persistenceDuration, err := meter.SyncInt64().Histogram("management.store.persistence.duration.micro",
+
+	globalLockAcquisitionDurationMs, err := meter.SyncInt64().Histogram("management.store.global.lock.acquisition.duration.ms")
+	if err != nil {
+		return nil, err
+	}
+
+	persistenceDurationMicro, err := meter.SyncInt64().Histogram("management.store.persistence.duration.micro",
 		instrument.WithUnit("microseconds"))
 	if err != nil {
 		return nil, err
 	}
 
+	persistenceDurationMs, err := meter.SyncInt64().Histogram("management.store.persistence.duration.ms")
+	if err != nil {
+		return nil, err
+	}
+
 	return &StoreMetrics{
-		globalLockAcquisitionDuration: globalLockAcquisitionDuration,
-		persistenceDuration:           persistenceDuration,
-		ctx:                           ctx,
+		globalLockAcquisitionDurationMicro: globalLockAcquisitionDurationMicro,
+		globalLockAcquisitionDurationMs:    globalLockAcquisitionDurationMs,
+		persistenceDurationMicro:           persistenceDurationMicro,
+		persistenceDurationMs:              persistenceDurationMs,
+		ctx:                                ctx,
 	}, nil
 }
 
 // CountGlobalLockAcquisitionDuration counts the duration of the global lock acquisition
 func (metrics *StoreMetrics) CountGlobalLockAcquisitionDuration(duration time.Duration) {
-	metrics.globalLockAcquisitionDuration.Record(metrics.ctx, duration.Microseconds())
+	metrics.globalLockAcquisitionDurationMicro.Record(metrics.ctx, duration.Microseconds())
+	metrics.globalLockAcquisitionDurationMs.Record(metrics.ctx, duration.Milliseconds())
 }
 
 // CountPersistenceDuration counts the duration of a store persistence operation
 func (metrics *StoreMetrics) CountPersistenceDuration(duration time.Duration) {
-	metrics.persistenceDuration.Record(metrics.ctx, duration.Microseconds())
+	metrics.persistenceDurationMicro.Record(metrics.ctx, duration.Microseconds())
+	metrics.persistenceDurationMs.Record(metrics.ctx, duration.Milliseconds())
 }
diff --git a/management/server/user.go b/management/server/user.go
index 23c6f228..a20a8b2b 100644
--- a/management/server/user.go
+++ b/management/server/user.go
@@ -338,8 +338,13 @@ func (am *DefaultAccountManager) DeleteUser(accountID, initiatorUserID string, t
 		return status.Errorf(status.Internal, "failed to find user peers")
 	}
 
-	if err := am.expireAndUpdatePeers(account, peers); err != nil {
-		log.Errorf("failed update deleted peers expiration: %s", err)
+	peerIDs := make([]string, 0, len(peers))
+	for _, peer := range peers {
+		peerIDs = append(peerIDs, peer.ID)
+	}
+
+	err = am.deletePeers(account, peerIDs, initiatorUserID)
+	if err != nil {
 		return err
 	}
 
@@ -370,6 +375,7 @@ func (am *DefaultAccountManager) DeleteUser(accountID, initiatorUserID string, t
 
 	delete(account.Users, targetUserID)
 
+	// todo should be unnecessary because we save account in the am.deletePeers
 	err = am.Store.SaveAccount(account)
 	if err != nil {
 		return err
@@ -836,6 +842,9 @@ func (am *DefaultAccountManager) GetUsersFromAccount(accountID, userID string) (
 func (am *DefaultAccountManager) expireAndUpdatePeers(account *Account, peers []*Peer) error {
 	var peerIDs []string
 	for _, peer := range peers {
+		if peer.Status.LoginExpired {
+			continue
+		}
 		peerIDs = append(peerIDs, peer.ID)
 		peer.MarkLoginExpired(true)
 		account.UpdatePeer(peer)
diff --git a/release_files/install.sh b/release_files/install.sh
index 3df08501..c553cc28 100755
--- a/release_files/install.sh
+++ b/release_files/install.sh
@@ -1,4 +1,3 @@
-#!/bin/sh
 # This code is based on the netbird-installer contribution by physk on GitHub.
 # Source: https://github.com/physk/netbird-installer
 set -e
@@ -17,6 +16,12 @@ OS_TYPE=""
 ARCH="$(uname -m)"
 PACKAGE_MANAGER="bin"
 INSTALL_DIR=""
+SUDO=""
+
+
+if command -v sudo > /dev/null && [ "$(id -u)" -ne 0 ]; then
+    SUDO="sudo"
+fi
 
 get_latest_release() {
     if [ -n "$GITHUB_TOKEN" ]; then
@@ -65,27 +70,35 @@ download_release_binary() {
         unzip -q -o "$BINARY_NAME"
         mv "netbird_ui_${OS_TYPE}_${ARCH}" "$INSTALL_DIR"
     else
-        sudo mkdir -p "$INSTALL_DIR"
+        ${SUDO} mkdir -p "$INSTALL_DIR"
         tar -xzvf "$BINARY_NAME"
-        sudo mv "${1%_"${BINARY_BASE_NAME}"}" "$INSTALL_DIR/"
+        ${SUDO} mv "${1%_"${BINARY_BASE_NAME}"}" "$INSTALL_DIR/"
     fi
 }
 
 add_apt_repo() {
-    sudo apt-get update
-    sudo apt-get install ca-certificates gnupg -y
+    ${SUDO} apt-get update
+    ${SUDO} apt-get install ca-certificates curl gnupg -y
 
-    curl -sSL https://pkgs.wiretrustee.com/debian/public.key \
-    | sudo gpg --dearmor --output /usr/share/keyrings/wiretrustee-archive-keyring.gpg
+    # Remove old keys and repo source files
+    ${SUDO} rm -f \
+        /etc/apt/sources.list.d/netbird.list \
+        /etc/apt/sources.list.d/wiretrustee.list \
+        /etc/apt/trusted.gpg.d/wiretrustee.gpg \
+        /usr/share/keyrings/netbird-archive-keyring.gpg \
+        /usr/share/keyrings/wiretrustee-archive-keyring.gpg
 
-    APT_REPO="deb [signed-by=/usr/share/keyrings/wiretrustee-archive-keyring.gpg] https://pkgs.wiretrustee.com/debian stable main"
-    echo "$APT_REPO" | sudo tee /etc/apt/sources.list.d/wiretrustee.list
+    curl -sSL https://pkgs.netbird.io/debian/public.key \
+    | ${SUDO} gpg --dearmor -o /usr/share/keyrings/netbird-archive-keyring.gpg
 
-    sudo apt-get update
+    echo 'deb [signed-by=/usr/share/keyrings/netbird-archive-keyring.gpg] https://pkgs.netbird.io/debian stable main' \
+    | ${SUDO} tee /etc/apt/sources.list.d/netbird.list
+
+    ${SUDO} apt-get update
 }
 
 add_rpm_repo() {
-cat <<-EOF | sudo tee /etc/yum.repos.d/netbird.repo
+cat <<-EOF | ${SUDO} tee /etc/yum.repos.d/netbird.repo
 [NetBird]
 name=NetBird
 baseurl=https://pkgs.netbird.io/yum/
@@ -104,7 +117,7 @@ add_aur_repo() {
     for PKG in $INSTALL_PKGS; do
         if ! pacman -Q "$PKG" > /dev/null 2>&1; then
             # Install missing package(s)
-            sudo pacman -S "$PKG" --noconfirm
+            ${SUDO} pacman -S "$PKG" --noconfirm
 
             # Add installed package for clean up later
             REMOVE_PKGS="$REMOVE_PKGS $PKG"
@@ -121,7 +134,7 @@ add_aur_repo() {
     fi
 
     # Clean up the installed packages
-    sudo pacman -Rs "$REMOVE_PKGS" --noconfirm
+    ${SUDO} pacman -Rs "$REMOVE_PKGS" --noconfirm
 }
 
 install_native_binaries() {
@@ -181,90 +194,36 @@ install_netbird() {
         fi
     fi
 
-    # Identify OS name and default package manager
-    if type uname >/dev/null 2>&1; then
-	case "$(uname)" in
-        Linux)
-            OS_NAME="$(. /etc/os-release && echo "$ID")"
-            OS_TYPE="linux"
-            INSTALL_DIR="/usr/bin"
-
-            # Allow netbird UI installation for x64 arch only
-            if [ "$ARCH" != "amd64" ] && [ "$ARCH" != "arm64" ] \
-                && [ "$ARCH" != "x86_64" ];then
-                SKIP_UI_APP=true
-                echo "NetBird UI installation will be omitted as $ARCH is not a compactible architecture"
-            fi
-
-            # Allow netbird UI installation for linux running desktop enviroment
-            if [ -z "$XDG_CURRENT_DESKTOP" ];then
-                SKIP_UI_APP=true
-                echo "NetBird UI installation will be omitted as Linux does not run desktop environment"
-            fi
-
-            # Check the availability of a compatible package manager
-            if check_use_bin_variable; then
-                PACKAGE_MANAGER="bin"
-            elif [ -x "$(command -v apt)" ]; then
-                PACKAGE_MANAGER="apt"
-                echo "The installation will be performed using apt package manager"
-            elif [ -x "$(command -v dnf)" ]; then
-                PACKAGE_MANAGER="dnf"
-                echo "The installation will be performed using dnf package manager"
-            elif [ -x "$(command -v yum)" ]; then
-                PACKAGE_MANAGER="yum"
-                echo "The installation will be performed using yum package manager"
-            elif [ -x "$(command -v pacman)" ]; then
-                PACKAGE_MANAGER="pacman"
-                echo "The installation will be performed using pacman package manager"
-            fi
-		;;
-		Darwin)
-            OS_NAME="macos"
-			OS_TYPE="darwin"
-            INSTALL_DIR="/usr/local/bin"
-
-            # Check the availability of a compatible package manager
-            if check_use_bin_variable; then
-                PACKAGE_MANAGER="bin"
-            elif [ -x "$(command -v brew)" ]; then
-                PACKAGE_MANAGER="brew"
-                echo "The installation will be performed using brew package manager"
-            fi
-		;;
-	esac
-    fi
-
     # Run the installation, if a desktop environment is not detected
     # only the CLI will be installed
     case "$PACKAGE_MANAGER" in
     apt)
         add_apt_repo
-        sudo apt-get install netbird -y
+        ${SUDO} apt-get install netbird -y
 
         if ! $SKIP_UI_APP; then
-            sudo apt-get install netbird-ui -y
+            ${SUDO} apt-get install netbird-ui -y
         fi
     ;;
     yum)
         add_rpm_repo
-        sudo yum -y install netbird
+        ${SUDO} yum -y install netbird
         if ! $SKIP_UI_APP; then
-            sudo yum -y install netbird-ui
+            ${SUDO} yum -y install netbird-ui
         fi
     ;;
     dnf)
         add_rpm_repo
-        sudo dnf -y install dnf-plugin-config-manager
-        sudo dnf config-manager --add-repo /etc/yum.repos.d/netbird.repo
-        sudo dnf -y install netbird
+        ${SUDO} dnf -y install dnf-plugin-config-manager
+        ${SUDO} dnf config-manager --add-repo /etc/yum.repos.d/netbird.repo
+        ${SUDO} dnf -y install netbird
 
         if ! $SKIP_UI_APP; then
-            sudo dnf -y install netbird-ui
+            ${SUDO} dnf -y install netbird-ui
         fi
     ;;
     pacman)
-        sudo pacman -Syy
+        ${SUDO} pacman -Syy
         add_aur_repo
     ;;
     brew)
@@ -297,7 +256,7 @@ install_netbird() {
 
         echo "Build and apply new configuration:"
         echo ""
-        echo "sudo nixos-rebuild switch"
+        echo "${SUDO} nixos-rebuild switch"
 			  exit 0
       fi
 
@@ -306,21 +265,21 @@ install_netbird() {
     esac
 
     # Add package manager to config
-    sudo mkdir -p "$CONFIG_FOLDER"
-    echo "package_manager=$PACKAGE_MANAGER" | sudo tee "$CONFIG_FILE" > /dev/null
+    ${SUDO} mkdir -p "$CONFIG_FOLDER"
+    echo "package_manager=$PACKAGE_MANAGER" | ${SUDO} tee "$CONFIG_FILE" > /dev/null
 
     # Load and start netbird service
-    if  ! sudo netbird service install 2>&1; then
+    if  ! ${SUDO} netbird service install 2>&1; then
         echo "NetBird service has already been loaded"
     fi
-    if  ! sudo netbird service start 2>&1; then
+    if  ! ${SUDO} netbird service start 2>&1; then
         echo "NetBird service has already been started"
     fi
 
 
     echo "Installation has been finished. To connect, you need to run NetBird by executing the following command:"
     echo ""
-    echo "sudo netbird up"
+    echo "netbird up"
 }
 
 version_greater_equal() {
@@ -328,7 +287,7 @@ version_greater_equal() {
 }
 
 is_bin_package_manager() {
-  if sudo test -f "$1" && sudo grep -q "package_manager=bin" "$1" ; then
+  if ${SUDO} test -f "$1" && ${SUDO} grep -q "package_manager=bin" "$1" ; then
     return 0
   else
     return 1
@@ -351,18 +310,71 @@ update_netbird() {
       echo ""
       echo "Initiating NetBird update. This will stop the netbird service and restart it after the update"
 
-      sudo netbird service stop
-      sudo netbird service uninstall
+      ${SUDO} netbird service stop
+      ${SUDO} netbird service uninstall
       install_native_binaries
 
-      sudo netbird service install
-      sudo netbird service start
+      ${SUDO} netbird service install
+      ${SUDO} netbird service start
     fi
   else
      echo "NetBird installation was done using a package manager. Please use your system's package manager to update"
   fi
 }
 
+# Identify OS name and default package manager
+if type uname >/dev/null 2>&1; then
+	case "$(uname)" in
+        Linux)
+            OS_NAME="$(. /etc/os-release && echo "$ID")"
+            OS_TYPE="linux"
+            INSTALL_DIR="/usr/bin"
+
+            # Allow netbird UI installation for x64 arch only
+            if [ "$ARCH" != "amd64" ] && [ "$ARCH" != "arm64" ] \
+                && [ "$ARCH" != "x86_64" ];then
+                SKIP_UI_APP=true
+                echo "NetBird UI installation will be omitted as $ARCH is not a compactible architecture"
+            fi
+
+            # Allow netbird UI installation for linux running desktop enviroment
+            if [ -z "$XDG_CURRENT_DESKTOP" ];then
+                SKIP_UI_APP=true
+                echo "NetBird UI installation will be omitted as Linux does not run desktop environment"
+            fi
+
+            # Check the availability of a compatible package manager
+            if check_use_bin_variable; then
+                PACKAGE_MANAGER="bin"
+            elif [ -x "$(command -v apt)" ]; then
+                PACKAGE_MANAGER="apt"
+                echo "The installation will be performed using apt package manager"
+            elif [ -x "$(command -v dnf)" ]; then
+                PACKAGE_MANAGER="dnf"
+                echo "The installation will be performed using dnf package manager"
+            elif [ -x "$(command -v yum)" ]; then
+                PACKAGE_MANAGER="yum"
+                echo "The installation will be performed using yum package manager"
+            elif [ -x "$(command -v pacman)" ]; then
+                PACKAGE_MANAGER="pacman"
+                echo "The installation will be performed using pacman package manager"
+            fi
+		;;
+		Darwin)
+            OS_NAME="macos"
+			OS_TYPE="darwin"
+            INSTALL_DIR="/usr/local/bin"
+
+            # Check the availability of a compatible package manager
+            if check_use_bin_variable; then
+                PACKAGE_MANAGER="bin"
+            elif [ -x "$(command -v brew)" ]; then
+                PACKAGE_MANAGER="brew"
+                echo "The installation will be performed using brew package manager"
+            fi
+		;;
+	esac
+fi
 
 case "$1" in
     --update)
@@ -370,4 +382,4 @@ case "$1" in
     ;;
     *)
       install_netbird
-esac
\ No newline at end of file
+esac
diff --git a/route/route.go b/route/route.go
index 5c45e2cf..eb7bcba2 100644
--- a/route/route.go
+++ b/route/route.go
@@ -70,6 +70,7 @@ type Route struct {
 	NetID       string
 	Description string
 	Peer        string
+	PeerGroups  []string
 	NetworkType NetworkType
 	Masquerade  bool
 	Metric      int
@@ -79,7 +80,7 @@ type Route struct {
 
 // EventMeta returns activity event meta related to the route
 func (r *Route) EventMeta() map[string]any {
-	return map[string]any{"name": r.NetID, "network_range": r.Network.String(), "peer_id": r.Peer}
+	return map[string]any{"name": r.NetID, "network_range": r.Network.String(), "peer_id": r.Peer, "peer_groups": r.PeerGroups}
 }
 
 // Copy copies a route object
@@ -91,12 +92,14 @@ func (r *Route) Copy() *Route {
 		Network:     r.Network,
 		NetworkType: r.NetworkType,
 		Peer:        r.Peer,
+		PeerGroups:  make([]string, len(r.PeerGroups)),
 		Metric:      r.Metric,
 		Masquerade:  r.Masquerade,
 		Enabled:     r.Enabled,
 		Groups:      make([]string, len(r.Groups)),
 	}
 	copy(route.Groups, r.Groups)
+	copy(route.PeerGroups, r.PeerGroups)
 	return route
 }
 
@@ -111,7 +114,8 @@ func (r *Route) IsEqual(other *Route) bool {
 		other.Metric == r.Metric &&
 		other.Masquerade == r.Masquerade &&
 		other.Enabled == r.Enabled &&
-		compareGroupsList(r.Groups, other.Groups)
+		compareList(r.Groups, other.Groups) &&
+		compareList(r.PeerGroups, other.PeerGroups)
 }
 
 // ParseNetwork Parses a network prefix string and returns a netip.Prefix object and if is invalid, IPv4 or IPv6
@@ -134,7 +138,7 @@ func ParseNetwork(networkString string) (NetworkType, netip.Prefix, error) {
 	return IPv4Network, masked, nil
 }
 
-func compareGroupsList(list, other []string) bool {
+func compareList(list, other []string) bool {
 	if len(list) != len(other) {
 		return false
 	}



Test patch:
diff --git a/management/server/account_test.go b/management/server/account_test.go
index 204e9894..5f711df8 100644
--- a/management/server/account_test.go
+++ b/management/server/account_test.go
@@ -1062,7 +1062,7 @@ func TestAccountManager_NetworkUpdates(t *testing.T) {
 			}
 		}()
 
-		if _, err := manager.DeletePeer(account.Id, peer3.ID, userID); err != nil {
+		if err := manager.DeletePeer(account.Id, peer3.ID, userID); err != nil {
 			t.Errorf("delete peer: %v", err)
 			return
 		}
@@ -1129,7 +1129,7 @@ func TestAccountManager_DeletePeer(t *testing.T) {
 		return
 	}
 
-	_, err = manager.DeletePeer(account.Id, peerKey, userID)
+	err = manager.DeletePeer(account.Id, peerKey, userID)
 	if err != nil {
 		return
 	}
@@ -1385,8 +1385,9 @@ func TestAccount_Copy(t *testing.T) {
 		},
 		Routes: map[string]*route.Route{
 			"route1": {
-				ID:     "route1",
-				Groups: []string{"group1"},
+				ID:         "route1",
+				PeerGroups: []string{},
+				Groups:     []string{"group1"},
 			},
 		},
 		NameServerGroups: map[string]*nbdns.NameServerGroup{
diff --git a/management/server/ephemeral_test.go b/management/server/ephemeral_test.go
index a763f4ce..d271e5fc 100644
--- a/management/server/ephemeral_test.go
+++ b/management/server/ephemeral_test.go
@@ -29,9 +29,9 @@ type MocAccountManager struct {
 	store *MockStore
 }
 
-func (a MocAccountManager) DeletePeer(accountID, peerID, userID string) (*Peer, error) {
+func (a MocAccountManager) DeletePeer(accountID, peerID, userID string) error {
 	delete(a.store.account.Peers, peerID)
-	return nil, nil //nolint:nilnil
+	return nil //nolint:nil
 }
 
 func TestNewManager(t *testing.T) {
diff --git a/management/server/http/routes_handler_test.go b/management/server/http/routes_handler_test.go
index 3f2b7b91..0f797c3b 100644
--- a/management/server/http/routes_handler_test.go
+++ b/management/server/http/routes_handler_test.go
@@ -23,16 +23,23 @@ import (
 )
 
 const (
-	existingRouteID = "existingRouteID"
-	notFoundRouteID = "notFoundRouteID"
-	existingPeerIP  = "100.64.0.100"
-	existingPeerID  = "peer-id"
-	notFoundPeerID  = "nonExistingPeer"
-	existingPeerKey = "existingPeerKey"
-	testAccountID   = "test_id"
-	existingGroupID = "testGroup"
+	existingRouteID         = "existingRouteID"
+	existingRouteID2        = "existingRouteID2" // for peer_groups test
+	notFoundRouteID         = "notFoundRouteID"
+	existingPeerIP1         = "100.64.0.100"
+	existingPeerIP2         = "100.64.0.101"
+	notFoundPeerID          = "nonExistingPeer"
+	existingPeerKey         = "existingPeerKey"
+	nonLinuxExistingPeerKey = "darwinExistingPeerKey"
+	testAccountID           = "test_id"
+	existingGroupID         = "testGroup"
+	notFoundGroupID         = "nonExistingGroup"
 )
 
+var emptyString = ""
+var existingPeerID = "peer-id"
+var nonLinuxExistingPeerID = "darwin-peer-id"
+
 var baseExistingRoute = &route.Route{
 	ID:          existingRouteID,
 	Description: "base route",
@@ -51,8 +58,19 @@ var testingAccount = &server.Account{
 	Peers: map[string]*server.Peer{
 		existingPeerID: {
 			Key: existingPeerKey,
-			IP:  netip.MustParseAddr(existingPeerIP).AsSlice(),
+			IP:  netip.MustParseAddr(existingPeerIP1).AsSlice(),
 			ID:  existingPeerID,
+			Meta: server.PeerSystemMeta{
+				GoOS: "linux",
+			},
+		},
+		nonLinuxExistingPeerID: {
+			Key: nonLinuxExistingPeerID,
+			IP:  netip.MustParseAddr(existingPeerIP2).AsSlice(),
+			ID:  nonLinuxExistingPeerID,
+			Meta: server.PeerSystemMeta{
+				GoOS: "darwin",
+			},
 		},
 	},
 	Users: map[string]*server.User{
@@ -67,17 +85,26 @@ func initRoutesTestData() *RoutesHandler {
 				if routeID == existingRouteID {
 					return baseExistingRoute, nil
 				}
+				if routeID == existingRouteID2 {
+					route := baseExistingRoute.Copy()
+					route.PeerGroups = []string{existingGroupID}
+					return route, nil
+				}
 				return nil, status.Errorf(status.NotFound, "route with ID %s not found", routeID)
 			},
-			CreateRouteFunc: func(accountID string, network, peerID, description, netID string, masquerade bool, metric int, groups []string, enabled bool, _ string) (*route.Route, error) {
+			CreateRouteFunc: func(accountID, network, peerID string, peerGroups []string, description, netID string, masquerade bool, metric int, groups []string, enabled bool, _ string) (*route.Route, error) {
 				if peerID == notFoundPeerID {
 					return nil, status.Errorf(status.InvalidArgument, "peer with ID %s not found", peerID)
 				}
+				if len(peerGroups) > 0 && peerGroups[0] == notFoundGroupID {
+					return nil, status.Errorf(status.InvalidArgument, "peer groups with ID %s not found", peerGroups[0])
+				}
 				networkType, p, _ := route.ParseNetwork(network)
 				return &route.Route{
 					ID:          existingRouteID,
 					NetID:       netID,
 					Peer:        peerID,
+					PeerGroups:  peerGroups,
 					Network:     p,
 					NetworkType: networkType,
 					Description: description,
@@ -124,6 +151,9 @@ func initRoutesTestData() *RoutesHandler {
 }
 
 func TestRoutesHandlers(t *testing.T) {
+	baseExistingRouteWithPeerGroups := baseExistingRoute.Copy()
+	baseExistingRouteWithPeerGroups.PeerGroups = []string{existingGroupID}
+
 	tt := []struct {
 		name           string
 		expectedStatus int
@@ -147,6 +177,14 @@ func TestRoutesHandlers(t *testing.T) {
 			requestPath:    "/api/routes/" + notFoundRouteID,
 			expectedStatus: http.StatusNotFound,
 		},
+		{
+			name:           "Get Existing Route with Peer Groups",
+			requestType:    http.MethodGet,
+			requestPath:    "/api/routes/" + existingRouteID2,
+			expectedStatus: http.StatusOK,
+			expectedBody:   true,
+			expectedRoute:  toRouteResponse(baseExistingRouteWithPeerGroups),
+		},
 		{
 			name:           "Delete Existing Route",
 			requestType:    http.MethodDelete,
@@ -173,13 +211,21 @@ func TestRoutesHandlers(t *testing.T) {
 				Description: "Post",
 				NetworkId:   "awesomeNet",
 				Network:     "192.168.0.0/16",
-				Peer:        existingPeerID,
+				Peer:        &existingPeerID,
 				NetworkType: route.IPv4NetworkString,
 				Masquerade:  false,
 				Enabled:     false,
 				Groups:      []string{existingGroupID},
 			},
 		},
+		{
+			name:           "POST Non Linux Peer",
+			requestType:    http.MethodPost,
+			requestPath:    "/api/routes",
+			requestBody:    bytes.NewBufferString(fmt.Sprintf("{\"Description\":\"Post\",\"Network\":\"192.168.0.0/16\",\"network_id\":\"awesomeNet\",\"Peer\":\"%s\",\"groups\":[\"%s\"]}", nonLinuxExistingPeerID, existingGroupID)),
+			expectedStatus: http.StatusUnprocessableEntity,
+			expectedBody:   false,
+		},
 		{
 			name:           "POST Not Found Peer",
 			requestType:    http.MethodPost,
@@ -204,6 +250,24 @@ func TestRoutesHandlers(t *testing.T) {
 			expectedStatus: http.StatusUnprocessableEntity,
 			expectedBody:   false,
 		},
+		{
+			name:        "POST UnprocessableEntity when both peer and peer_groups are provided",
+			requestType: http.MethodPost,
+			requestPath: "/api/routes",
+			requestBody: bytes.NewBuffer(
+				[]byte(fmt.Sprintf("{\"Description\":\"Post\",\"Network\":\"192.168.0.0/16\",\"network_id\":\"awesomeNet\",\"peer\":\"%s\",\"peer_groups\":[\"%s\"],\"groups\":[\"%s\"]}", existingPeerID, existingGroupID, existingGroupID))),
+			expectedStatus: http.StatusUnprocessableEntity,
+			expectedBody:   false,
+		},
+		{
+			name:        "POST UnprocessableEntity when no peer and peer_groups are provided",
+			requestType: http.MethodPost,
+			requestPath: "/api/routes",
+			requestBody: bytes.NewBuffer(
+				[]byte(fmt.Sprintf("{\"Description\":\"Post\",\"Network\":\"192.168.0.0/16\",\"network_id\":\"awesomeNet\",\"groups\":[\"%s\"]}", existingPeerID))),
+			expectedStatus: http.StatusUnprocessableEntity,
+			expectedBody:   false,
+		},
 		{
 			name:           "PUT OK",
 			requestType:    http.MethodPut,
@@ -216,7 +280,27 @@ func TestRoutesHandlers(t *testing.T) {
 				Description: "Post",
 				NetworkId:   "awesomeNet",
 				Network:     "192.168.0.0/16",
-				Peer:        existingPeerID,
+				Peer:        &existingPeerID,
+				NetworkType: route.IPv4NetworkString,
+				Masquerade:  false,
+				Enabled:     false,
+				Groups:      []string{existingGroupID},
+			},
+		},
+		{
+			name:           "PUT OK when peer_groups provided",
+			requestType:    http.MethodPut,
+			requestPath:    "/api/routes/" + existingRouteID,
+			requestBody:    bytes.NewBufferString(fmt.Sprintf("{\"Description\":\"Post\",\"Network\":\"192.168.0.0/16\",\"network_id\":\"awesomeNet\",\"peer_groups\":[\"%s\"],\"groups\":[\"%s\"]}", existingGroupID, existingGroupID)),
+			expectedStatus: http.StatusOK,
+			expectedBody:   true,
+			expectedRoute: &api.Route{
+				Id:          existingRouteID,
+				Description: "Post",
+				NetworkId:   "awesomeNet",
+				Network:     "192.168.0.0/16",
+				Peer:        &emptyString,
+				PeerGroups:  &[]string{existingGroupID},
 				NetworkType: route.IPv4NetworkString,
 				Masquerade:  false,
 				Enabled:     false,
@@ -239,6 +323,14 @@ func TestRoutesHandlers(t *testing.T) {
 			expectedStatus: http.StatusUnprocessableEntity,
 			expectedBody:   false,
 		},
+		{
+			name:           "PUT Non Linux Peer",
+			requestType:    http.MethodPut,
+			requestPath:    "/api/routes/" + existingRouteID,
+			requestBody:    bytes.NewBufferString(fmt.Sprintf("{\"Description\":\"Post\",\"Network\":\"192.168.0.0/16\",\"network_id\":\"awesomeNet\",\"Peer\":\"%s\",\"groups\":[\"%s\"]}", nonLinuxExistingPeerID, existingGroupID)),
+			expectedStatus: http.StatusUnprocessableEntity,
+			expectedBody:   false,
+		},
 		{
 			name:           "PUT Invalid Network Identifier",
 			requestType:    http.MethodPut,
@@ -255,6 +347,24 @@ func TestRoutesHandlers(t *testing.T) {
 			expectedStatus: http.StatusUnprocessableEntity,
 			expectedBody:   false,
 		},
+		{
+			name:        "PUT UnprocessableEntity when both peer and peer_groups are provided",
+			requestType: http.MethodPut,
+			requestPath: "/api/routes/" + existingRouteID,
+			requestBody: bytes.NewBuffer(
+				[]byte(fmt.Sprintf("{\"Description\":\"Post\",\"Network\":\"192.168.0.0/16\",\"network_id\":\"awesomeNet\",\"peer\":\"%s\",\"peer_groups\":[\"%s\"],\"groups\":[\"%s\"]}", existingPeerID, existingGroupID, existingGroupID))),
+			expectedStatus: http.StatusUnprocessableEntity,
+			expectedBody:   false,
+		},
+		{
+			name:        "PUT UnprocessableEntity when no peer and peer_groups are provided",
+			requestType: http.MethodPut,
+			requestPath: "/api/routes/" + existingRouteID,
+			requestBody: bytes.NewBuffer(
+				[]byte(fmt.Sprintf("{\"Description\":\"Post\",\"Network\":\"192.168.0.0/16\",\"network_id\":\"awesomeNet\",\"groups\":[\"%s\"]}", existingPeerID))),
+			expectedStatus: http.StatusUnprocessableEntity,
+			expectedBody:   false,
+		},
 	}
 
 	p := initRoutesTestData()
diff --git a/management/server/idp/azure_test.go b/management/server/idp/azure_test.go
index 9d845ffb..b4dc96b2 100644
--- a/management/server/idp/azure_test.go
+++ b/management/server/idp/azure_test.go
@@ -8,15 +8,6 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-type mockAzureCredentials struct {
-	jwtToken JWTToken
-	err      error
-}
-
-func (mc *mockAzureCredentials) Authenticate() (JWTToken, error) {
-	return mc.jwtToken, mc.err
-}
-
 func TestAzureJwtStillValid(t *testing.T) {
 	type jwtStillValidTest struct {
 		name           string
@@ -124,206 +115,63 @@ func TestAzureAuthenticate(t *testing.T) {
 	}
 }
 
-func TestAzureUpdateUserAppMetadata(t *testing.T) {
-	type updateUserAppMetadataTest struct {
-		name                 string
-		inputReqBody         string
-		expectedReqBody      string
-		appMetadata          AppMetadata
-		statusCode           int
-		helper               ManagerHelper
-		managerCreds         ManagerCredentials
-		assertErrFunc        assert.ErrorAssertionFunc
-		assertErrFuncMessage string
-	}
-
-	appMetadata := AppMetadata{WTAccountID: "ok"}
-
-	updateUserAppMetadataTestCase1 := updateUserAppMetadataTest{
-		name:            "Bad Authentication",
-		expectedReqBody: "",
-		appMetadata:     appMetadata,
-		statusCode:      400,
-		helper:          JsonParser{},
-		managerCreds: &mockAzureCredentials{
-			jwtToken: JWTToken{},
-			err:      fmt.Errorf("error"),
-		},
-		assertErrFunc:        assert.Error,
-		assertErrFuncMessage: "should return error",
-	}
-
-	updateUserAppMetadataTestCase2 := updateUserAppMetadataTest{
-		name:            "Bad Status Code",
-		expectedReqBody: fmt.Sprintf("{\"extension__wt_account_id\":\"%s\",\"extension__wt_pending_invite\":null}", appMetadata.WTAccountID),
-		appMetadata:     appMetadata,
-		statusCode:      400,
-		helper:          JsonParser{},
-		managerCreds: &mockAzureCredentials{
-			jwtToken: JWTToken{},
-		},
-		assertErrFunc:        assert.Error,
-		assertErrFuncMessage: "should return error",
-	}
-
-	updateUserAppMetadataTestCase3 := updateUserAppMetadataTest{
-		name:       "Bad Response Parsing",
-		statusCode: 400,
-		helper:     &mockJsonParser{marshalErrorString: "error"},
-		managerCreds: &mockAzureCredentials{
-			jwtToken: JWTToken{},
-		},
-		assertErrFunc:        assert.Error,
-		assertErrFuncMessage: "should return error",
-	}
-
-	updateUserAppMetadataTestCase4 := updateUserAppMetadataTest{
-		name:            "Good request",
-		expectedReqBody: fmt.Sprintf("{\"extension__wt_account_id\":\"%s\",\"extension__wt_pending_invite\":null}", appMetadata.WTAccountID),
-		appMetadata:     appMetadata,
-		statusCode:      204,
-		helper:          JsonParser{},
-		managerCreds: &mockAzureCredentials{
-			jwtToken: JWTToken{},
-		},
-		assertErrFunc:        assert.NoError,
-		assertErrFuncMessage: "shouldn't return error",
-	}
-
-	invite := true
-	updateUserAppMetadataTestCase5 := updateUserAppMetadataTest{
-		name:            "Update Pending Invite",
-		expectedReqBody: fmt.Sprintf("{\"extension__wt_account_id\":\"%s\",\"extension__wt_pending_invite\":true}", appMetadata.WTAccountID),
-		appMetadata: AppMetadata{
-			WTAccountID:     "ok",
-			WTPendingInvite: &invite,
-		},
-		statusCode: 204,
-		helper:     JsonParser{},
-		managerCreds: &mockAzureCredentials{
-			jwtToken: JWTToken{},
-		},
-		assertErrFunc:        assert.NoError,
-		assertErrFuncMessage: "shouldn't return error",
-	}
-
-	for _, testCase := range []updateUserAppMetadataTest{updateUserAppMetadataTestCase1, updateUserAppMetadataTestCase2,
-		updateUserAppMetadataTestCase3, updateUserAppMetadataTestCase4, updateUserAppMetadataTestCase5} {
-		t.Run(testCase.name, func(t *testing.T) {
-			reqClient := mockHTTPClient{
-				resBody: testCase.inputReqBody,
-				code:    testCase.statusCode,
-			}
-
-			manager := &AzureManager{
-				httpClient:  &reqClient,
-				credentials: testCase.managerCreds,
-				helper:      testCase.helper,
-			}
-
-			err := manager.UpdateUserAppMetadata("1", testCase.appMetadata)
-			testCase.assertErrFunc(t, err, testCase.assertErrFuncMessage)
-
-			assert.Equal(t, testCase.expectedReqBody, reqClient.reqBody, "request body should match")
-		})
-	}
-}
-
 func TestAzureProfile(t *testing.T) {
 	type azureProfileTest struct {
 		name             string
-		clientID         string
 		invite           bool
 		inputProfile     azureProfile
 		expectedUserData UserData
 	}
 
 	azureProfileTestCase1 := azureProfileTest{
-		name:     "Good Request",
-		clientID: "25d0b095-0484-40d2-9fd3-03f8f4abbb3c",
-		invite:   false,
+		name:   "Good Request",
+		invite: false,
 		inputProfile: azureProfile{
 			"id":                "test1",
 			"displayName":       "John Doe",
 			"userPrincipalName": "test1@test.com",
-			"extension_25d0b095048440d29fd303f8f4abbb3c_wt_account_id":     "1",
-			"extension_25d0b095048440d29fd303f8f4abbb3c_wt_pending_invite": false,
 		},
 		expectedUserData: UserData{
 			Email: "test1@test.com",
 			Name:  "John Doe",
 			ID:    "test1",
-			AppMetadata: AppMetadata{
-				WTAccountID: "1",
-			},
 		},
 	}
 
 	azureProfileTestCase2 := azureProfileTest{
-		name:     "Missing User ID",
-		clientID: "25d0b095-0484-40d2-9fd3-03f8f4abbb3c",
-		invite:   true,
+		name:   "Missing User ID",
+		invite: true,
 		inputProfile: azureProfile{
 			"displayName":       "John Doe",
 			"userPrincipalName": "test2@test.com",
-			"extension_25d0b095048440d29fd303f8f4abbb3c_wt_account_id":     "1",
-			"extension_25d0b095048440d29fd303f8f4abbb3c_wt_pending_invite": true,
 		},
 		expectedUserData: UserData{
 			Email: "test2@test.com",
 			Name:  "John Doe",
-			AppMetadata: AppMetadata{
-				WTAccountID: "1",
-			},
 		},
 	}
 
 	azureProfileTestCase3 := azureProfileTest{
-		name:     "Missing User Name",
-		clientID: "25d0b095-0484-40d2-9fd3-03f8f4abbb3c",
-		invite:   false,
+		name:   "Missing User Name",
+		invite: false,
 		inputProfile: azureProfile{
 			"id":                "test3",
 			"userPrincipalName": "test3@test.com",
-			"extension_25d0b095048440d29fd303f8f4abbb3c_wt_account_id":     "1",
-			"extension_25d0b095048440d29fd303f8f4abbb3c_wt_pending_invite": false,
 		},
 		expectedUserData: UserData{
 			ID:    "test3",
 			Email: "test3@test.com",
-			AppMetadata: AppMetadata{
-				WTAccountID: "1",
-			},
-		},
-	}
-
-	azureProfileTestCase4 := azureProfileTest{
-		name:     "Missing Extension Fields",
-		clientID: "25d0b095-0484-40d2-9fd3-03f8f4abbb3c",
-		invite:   false,
-		inputProfile: azureProfile{
-			"id":                "test4",
-			"displayName":       "John Doe",
-			"userPrincipalName": "test4@test.com",
-		},
-		expectedUserData: UserData{
-			ID:          "test4",
-			Name:        "John Doe",
-			Email:       "test4@test.com",
-			AppMetadata: AppMetadata{},
 		},
 	}
 
-	for _, testCase := range []azureProfileTest{azureProfileTestCase1, azureProfileTestCase2, azureProfileTestCase3, azureProfileTestCase4} {
+	for _, testCase := range []azureProfileTest{azureProfileTestCase1, azureProfileTestCase2, azureProfileTestCase3} {
 		t.Run(testCase.name, func(t *testing.T) {
 			testCase.expectedUserData.AppMetadata.WTPendingInvite = &testCase.invite
-			userData := testCase.inputProfile.userData(testCase.clientID)
+			userData := testCase.inputProfile.userData()
 
 			assert.Equal(t, testCase.expectedUserData.ID, userData.ID, "User id should match")
 			assert.Equal(t, testCase.expectedUserData.Email, userData.Email, "User email should match")
 			assert.Equal(t, testCase.expectedUserData.Name, userData.Name, "User name should match")
-			assert.Equal(t, testCase.expectedUserData.AppMetadata.WTAccountID, userData.AppMetadata.WTAccountID, "Account id should match")
-			assert.Equal(t, testCase.expectedUserData.AppMetadata.WTPendingInvite, userData.AppMetadata.WTPendingInvite, "Pending invite should match")
 		})
 	}
 }
diff --git a/management/server/idp/keycloak_test.go b/management/server/idp/keycloak_test.go
index 0c33fc13..9b6c1d3c 100644
--- a/management/server/idp/keycloak_test.go
+++ b/management/server/idp/keycloak_test.go
@@ -84,15 +84,6 @@ func TestNewKeycloakManager(t *testing.T) {
 	}
 }
 
-type mockKeycloakCredentials struct {
-	jwtToken JWTToken
-	err      error
-}
-
-func (mc *mockKeycloakCredentials) Authenticate() (JWTToken, error) {
-	return mc.jwtToken, mc.err
-}
-
 func TestKeycloakRequestJWTToken(t *testing.T) {
 
 	type requestJWTTokenTest struct {
@@ -316,108 +307,3 @@ func TestKeycloakAuthenticate(t *testing.T) {
 		})
 	}
 }
-
-func TestKeycloakUpdateUserAppMetadata(t *testing.T) {
-	type updateUserAppMetadataTest struct {
-		name                 string
-		inputReqBody         string
-		expectedReqBody      string
-		appMetadata          AppMetadata
-		statusCode           int
-		helper               ManagerHelper
-		managerCreds         ManagerCredentials
-		assertErrFunc        assert.ErrorAssertionFunc
-		assertErrFuncMessage string
-	}
-
-	appMetadata := AppMetadata{WTAccountID: "ok"}
-
-	updateUserAppMetadataTestCase1 := updateUserAppMetadataTest{
-		name:            "Bad Authentication",
-		expectedReqBody: "",
-		appMetadata:     appMetadata,
-		statusCode:      400,
-		helper:          JsonParser{},
-		managerCreds: &mockKeycloakCredentials{
-			jwtToken: JWTToken{},
-			err:      fmt.Errorf("error"),
-		},
-		assertErrFunc:        assert.Error,
-		assertErrFuncMessage: "should return error",
-	}
-
-	updateUserAppMetadataTestCase2 := updateUserAppMetadataTest{
-		name:            "Bad Status Code",
-		expectedReqBody: fmt.Sprintf("{\"attributes\":{\"wt_account_id\":[\"%s\"],\"wt_pending_invite\":[\"false\"]}}", appMetadata.WTAccountID),
-		appMetadata:     appMetadata,
-		statusCode:      400,
-		helper:          JsonParser{},
-		managerCreds: &mockKeycloakCredentials{
-			jwtToken: JWTToken{},
-		},
-		assertErrFunc:        assert.Error,
-		assertErrFuncMessage: "should return error",
-	}
-
-	updateUserAppMetadataTestCase3 := updateUserAppMetadataTest{
-		name:       "Bad Response Parsing",
-		statusCode: 400,
-		helper:     &mockJsonParser{marshalErrorString: "error"},
-		managerCreds: &mockKeycloakCredentials{
-			jwtToken: JWTToken{},
-		},
-		assertErrFunc:        assert.Error,
-		assertErrFuncMessage: "should return error",
-	}
-
-	updateUserAppMetadataTestCase4 := updateUserAppMetadataTest{
-		name:            "Good request",
-		expectedReqBody: fmt.Sprintf("{\"attributes\":{\"wt_account_id\":[\"%s\"],\"wt_pending_invite\":[\"false\"]}}", appMetadata.WTAccountID),
-		appMetadata:     appMetadata,
-		statusCode:      204,
-		helper:          JsonParser{},
-		managerCreds: &mockKeycloakCredentials{
-			jwtToken: JWTToken{},
-		},
-		assertErrFunc:        assert.NoError,
-		assertErrFuncMessage: "shouldn't return error",
-	}
-
-	invite := true
-	updateUserAppMetadataTestCase5 := updateUserAppMetadataTest{
-		name:            "Update Pending Invite",
-		expectedReqBody: fmt.Sprintf("{\"attributes\":{\"wt_account_id\":[\"%s\"],\"wt_pending_invite\":[\"true\"]}}", appMetadata.WTAccountID),
-		appMetadata: AppMetadata{
-			WTAccountID:     "ok",
-			WTPendingInvite: &invite,
-		},
-		statusCode: 204,
-		helper:     JsonParser{},
-		managerCreds: &mockKeycloakCredentials{
-			jwtToken: JWTToken{},
-		},
-		assertErrFunc:        assert.NoError,
-		assertErrFuncMessage: "shouldn't return error",
-	}
-
-	for _, testCase := range []updateUserAppMetadataTest{updateUserAppMetadataTestCase1, updateUserAppMetadataTestCase2,
-		updateUserAppMetadataTestCase3, updateUserAppMetadataTestCase4, updateUserAppMetadataTestCase5} {
-		t.Run(testCase.name, func(t *testing.T) {
-			reqClient := mockHTTPClient{
-				resBody: testCase.inputReqBody,
-				code:    testCase.statusCode,
-			}
-
-			manager := &KeycloakManager{
-				httpClient:  &reqClient,
-				credentials: testCase.managerCreds,
-				helper:      testCase.helper,
-			}
-
-			err := manager.UpdateUserAppMetadata("1", testCase.appMetadata)
-			testCase.assertErrFunc(t, err, testCase.assertErrFuncMessage)
-
-			assert.Equal(t, testCase.expectedReqBody, reqClient.reqBody, "request body should match")
-		})
-	}
-}
diff --git a/management/server/idp/okta_test.go b/management/server/idp/okta_test.go
index 02c28b3a..20df246f 100644
--- a/management/server/idp/okta_test.go
+++ b/management/server/idp/okta_test.go
@@ -1,31 +1,28 @@
 package idp
 
 import (
+	"testing"
+
 	"github.com/okta/okta-sdk-golang/v2/okta"
 	"github.com/stretchr/testify/assert"
-	"testing"
 )
 
 func TestParseOktaUser(t *testing.T) {
 	type parseOktaUserTest struct {
 		name             string
-		invite           bool
 		inputProfile     *okta.User
 		expectedUserData *UserData
 		assertErrFunc    assert.ErrorAssertionFunc
 	}
 
 	parseOktaTestCase1 := parseOktaUserTest{
-		name:   "Good Request",
-		invite: true,
+		name: "Good Request",
 		inputProfile: &okta.User{
 			Id: "123",
 			Profile: &okta.UserProfile{
-				"email":             "test@example.com",
-				"firstName":         "John",
-				"lastName":          "Doe",
-				"wt_account_id":     "456",
-				"wt_pending_invite": true,
+				"email":     "test@example.com",
+				"firstName": "John",
+				"lastName":  "Doe",
 			},
 		},
 		expectedUserData: &UserData{
@@ -41,36 +38,17 @@ func TestParseOktaUser(t *testing.T) {
 
 	parseOktaTestCase2 := parseOktaUserTest{
 		name:             "Invalid okta user",
-		invite:           true,
 		inputProfile:     nil,
 		expectedUserData: nil,
 		assertErrFunc:    assert.Error,
 	}
 
-	parseOktaTestCase3 := parseOktaUserTest{
-		name:   "Invalid pending invite type",
-		invite: false,
-		inputProfile: &okta.User{
-			Id: "123",
-			Profile: &okta.UserProfile{
-				"email":             "test@example.com",
-				"firstName":         "John",
-				"lastName":          "Doe",
-				"wt_account_id":     "456",
-				"wt_pending_invite": "true",
-			},
-		},
-		expectedUserData: nil,
-		assertErrFunc:    assert.Error,
-	}
-
-	for _, testCase := range []parseOktaUserTest{parseOktaTestCase1, parseOktaTestCase2, parseOktaTestCase3} {
+	for _, testCase := range []parseOktaUserTest{parseOktaTestCase1, parseOktaTestCase2} {
 		t.Run(testCase.name, func(t *testing.T) {
 			userData, err := parseOktaUser(testCase.inputProfile)
 			testCase.assertErrFunc(t, err, testCase.assertErrFunc)
 
 			if err == nil {
-				testCase.expectedUserData.AppMetadata.WTPendingInvite = &testCase.invite
 				assert.True(t, userDataEqual(testCase.expectedUserData, userData), "user data should match")
 			}
 		})
@@ -83,13 +61,5 @@ func userDataEqual(a, b *UserData) bool {
 	if a.Email != b.Email || a.Name != b.Name || a.ID != b.ID {
 		return false
 	}
-	if a.AppMetadata.WTAccountID != b.AppMetadata.WTAccountID {
-		return false
-	}
-
-	if a.AppMetadata.WTPendingInvite != nil && b.AppMetadata.WTPendingInvite != nil &&
-		*a.AppMetadata.WTPendingInvite != *b.AppMetadata.WTPendingInvite {
-		return false
-	}
 	return true
 }
diff --git a/management/server/idp/zitadel_test.go b/management/server/idp/zitadel_test.go
index b558bba7..9a771b36 100644
--- a/management/server/idp/zitadel_test.go
+++ b/management/server/idp/zitadel_test.go
@@ -7,9 +7,10 @@ import (
 	"testing"
 	"time"
 
-	"github.com/netbirdio/netbird/management/server/telemetry"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+
+	"github.com/netbirdio/netbird/management/server/telemetry"
 )
 
 func TestNewZitadelManager(t *testing.T) {
@@ -63,15 +64,6 @@ func TestNewZitadelManager(t *testing.T) {
 	}
 }
 
-type mockZitadelCredentials struct {
-	jwtToken JWTToken
-	err      error
-}
-
-func (mc *mockZitadelCredentials) Authenticate() (JWTToken, error) {
-	return mc.jwtToken, mc.err
-}
-
 func TestZitadelRequestJWTToken(t *testing.T) {
 
 	type requestJWTTokenTest struct {
@@ -296,98 +288,6 @@ func TestZitadelAuthenticate(t *testing.T) {
 	}
 }
 
-func TestZitadelUpdateUserAppMetadata(t *testing.T) {
-	type updateUserAppMetadataTest struct {
-		name                 string
-		inputReqBody         string
-		expectedReqBody      string
-		appMetadata          AppMetadata
-		statusCode           int
-		helper               ManagerHelper
-		managerCreds         ManagerCredentials
-		assertErrFunc        assert.ErrorAssertionFunc
-		assertErrFuncMessage string
-	}
-
-	appMetadata := AppMetadata{WTAccountID: "ok"}
-
-	updateUserAppMetadataTestCase1 := updateUserAppMetadataTest{
-		name:            "Bad Authentication",
-		expectedReqBody: "",
-		appMetadata:     appMetadata,
-		statusCode:      400,
-		helper:          JsonParser{},
-		managerCreds: &mockZitadelCredentials{
-			jwtToken: JWTToken{},
-			err:      fmt.Errorf("error"),
-		},
-		assertErrFunc:        assert.Error,
-		assertErrFuncMessage: "should return error",
-	}
-
-	updateUserAppMetadataTestCase2 := updateUserAppMetadataTest{
-		name:       "Bad Response Parsing",
-		statusCode: 400,
-		helper:     &mockJsonParser{marshalErrorString: "error"},
-		managerCreds: &mockZitadelCredentials{
-			jwtToken: JWTToken{},
-		},
-		assertErrFunc:        assert.Error,
-		assertErrFuncMessage: "should return error",
-	}
-
-	updateUserAppMetadataTestCase3 := updateUserAppMetadataTest{
-		name:            "Good request",
-		expectedReqBody: "{\"metadata\":[{\"key\":\"wt_account_id\",\"value\":\"b2s=\"},{\"key\":\"wt_pending_invite\",\"value\":\"ZmFsc2U=\"}]}",
-		appMetadata:     appMetadata,
-		statusCode:      200,
-		helper:          JsonParser{},
-		managerCreds: &mockZitadelCredentials{
-			jwtToken: JWTToken{},
-		},
-		assertErrFunc:        assert.NoError,
-		assertErrFuncMessage: "shouldn't return error",
-	}
-
-	invite := true
-	updateUserAppMetadataTestCase4 := updateUserAppMetadataTest{
-		name:            "Update Pending Invite",
-		expectedReqBody: "{\"metadata\":[{\"key\":\"wt_account_id\",\"value\":\"b2s=\"},{\"key\":\"wt_pending_invite\",\"value\":\"dHJ1ZQ==\"}]}",
-		appMetadata: AppMetadata{
-			WTAccountID:     "ok",
-			WTPendingInvite: &invite,
-		},
-		statusCode: 200,
-		helper:     JsonParser{},
-		managerCreds: &mockZitadelCredentials{
-			jwtToken: JWTToken{},
-		},
-		assertErrFunc:        assert.NoError,
-		assertErrFuncMessage: "shouldn't return error",
-	}
-
-	for _, testCase := range []updateUserAppMetadataTest{updateUserAppMetadataTestCase1, updateUserAppMetadataTestCase2,
-		updateUserAppMetadataTestCase3, updateUserAppMetadataTestCase4} {
-		t.Run(testCase.name, func(t *testing.T) {
-			reqClient := mockHTTPClient{
-				resBody: testCase.inputReqBody,
-				code:    testCase.statusCode,
-			}
-
-			manager := &ZitadelManager{
-				httpClient:  &reqClient,
-				credentials: testCase.managerCreds,
-				helper:      testCase.helper,
-			}
-
-			err := manager.UpdateUserAppMetadata("1", testCase.appMetadata)
-			testCase.assertErrFunc(t, err, testCase.assertErrFuncMessage)
-
-			assert.Equal(t, testCase.expectedReqBody, reqClient.reqBody, "request body should match")
-		})
-	}
-}
-
 func TestZitadelProfile(t *testing.T) {
 	type azureProfileTest struct {
 		name             string
@@ -418,16 +318,6 @@ func TestZitadelProfile(t *testing.T) {
 					IsEmailVerified: true,
 				},
 			},
-			Metadata: []zitadelMetadata{
-				{
-					Key:   "wt_account_id",
-					Value: "MQ==",
-				},
-				{
-					Key:   "wt_pending_invite",
-					Value: "ZmFsc2U=",
-				},
-			},
 		},
 		expectedUserData: UserData{
 			ID:    "test1",
@@ -451,16 +341,6 @@ func TestZitadelProfile(t *testing.T) {
 				"machine",
 			},
 			Human: nil,
-			Metadata: []zitadelMetadata{
-				{
-					Key:   "wt_account_id",
-					Value: "MQ==",
-				},
-				{
-					Key:   "wt_pending_invite",
-					Value: "dHJ1ZQ==",
-				},
-			},
 		},
 		expectedUserData: UserData{
 			ID:    "test2",
@@ -480,8 +360,6 @@ func TestZitadelProfile(t *testing.T) {
 			assert.Equal(t, testCase.expectedUserData.ID, userData.ID, "User id should match")
 			assert.Equal(t, testCase.expectedUserData.Email, userData.Email, "User email should match")
 			assert.Equal(t, testCase.expectedUserData.Name, userData.Name, "User name should match")
-			assert.Equal(t, testCase.expectedUserData.AppMetadata.WTAccountID, userData.AppMetadata.WTAccountID, "Account id should match")
-			assert.Equal(t, testCase.expectedUserData.AppMetadata.WTPendingInvite, userData.AppMetadata.WTPendingInvite, "Pending invite should match")
 		})
 	}
 }
diff --git a/management/server/metrics/selfhosted_test.go b/management/server/metrics/selfhosted_test.go
new file mode 100644
index 00000000..c61613fd
--- /dev/null
+++ b/management/server/metrics/selfhosted_test.go
@@ -0,0 +1,239 @@
+package metrics
+
+import (
+	"testing"
+
+	nbdns "github.com/netbirdio/netbird/dns"
+	"github.com/netbirdio/netbird/management/server"
+	"github.com/netbirdio/netbird/route"
+)
+
+type mockDatasource struct{}
+
+// GetAllConnectedPeers returns a map of connected peer IDs for use in tests with predefined information
+func (mockDatasource) GetAllConnectedPeers() map[string]struct{} {
+	return map[string]struct{}{
+		"1": {},
+	}
+}
+
+// GetAllAccounts returns a list of *server.Account for use in tests with predefined information
+func (mockDatasource) GetAllAccounts() []*server.Account {
+	return []*server.Account{
+		{
+			Id:       "1",
+			Settings: &server.Settings{PeerLoginExpirationEnabled: true},
+			SetupKeys: map[string]*server.SetupKey{
+				"1": {
+					Id:        "1",
+					Ephemeral: true,
+					UsedTimes: 1,
+				},
+			},
+			Groups: map[string]*server.Group{
+				"1": {},
+				"2": {},
+			},
+			NameServerGroups: map[string]*nbdns.NameServerGroup{
+				"1": {},
+			},
+			Peers: map[string]*server.Peer{
+				"1": {
+					ID:         "1",
+					UserID:     "test",
+					SSHEnabled: true,
+					Meta:       server.PeerSystemMeta{GoOS: "linux", WtVersion: "0.0.1"},
+				},
+			},
+			Policies: []*server.Policy{
+				{
+					Rules: []*server.PolicyRule{
+						{
+							Bidirectional: true,
+							Protocol:      server.PolicyRuleProtocolTCP,
+						},
+					},
+				},
+				{
+					Rules: []*server.PolicyRule{
+						{
+							Bidirectional: false,
+							Protocol:      server.PolicyRuleProtocolTCP,
+						},
+					},
+				},
+			},
+			Routes: map[string]*route.Route{
+				"1": {
+					ID:         "1",
+					PeerGroups: make([]string, 1),
+				},
+			},
+			Users: map[string]*server.User{
+				"1": {
+					IsServiceUser: true,
+					PATs: map[string]*server.PersonalAccessToken{
+						"1": {},
+					},
+				},
+				"2": {
+					IsServiceUser: false,
+					PATs: map[string]*server.PersonalAccessToken{
+						"1": {},
+					},
+				},
+			},
+		},
+		{
+			Id:       "2",
+			Settings: &server.Settings{PeerLoginExpirationEnabled: true},
+			SetupKeys: map[string]*server.SetupKey{
+				"1": {
+					Id:        "1",
+					Ephemeral: true,
+					UsedTimes: 1,
+				},
+			},
+			Groups: map[string]*server.Group{
+				"1": {},
+				"2": {},
+			},
+			NameServerGroups: map[string]*nbdns.NameServerGroup{
+				"1": {},
+			},
+			Peers: map[string]*server.Peer{
+				"1": {
+					ID:         "1",
+					UserID:     "test",
+					SSHEnabled: true,
+					Meta:       server.PeerSystemMeta{GoOS: "linux", WtVersion: "0.0.1"},
+				},
+			},
+			Policies: []*server.Policy{
+				{
+					Rules: []*server.PolicyRule{
+						{
+							Bidirectional: true,
+							Protocol:      server.PolicyRuleProtocolTCP,
+						},
+					},
+				},
+				{
+					Rules: []*server.PolicyRule{
+						{
+							Bidirectional: false,
+							Protocol:      server.PolicyRuleProtocolTCP,
+						},
+					},
+				},
+			},
+			Routes: map[string]*route.Route{
+				"1": {
+					ID:         "1",
+					PeerGroups: make([]string, 1),
+				},
+			},
+			Users: map[string]*server.User{
+				"1": {
+					IsServiceUser: true,
+					PATs: map[string]*server.PersonalAccessToken{
+						"1": {},
+					},
+				},
+				"2": {
+					IsServiceUser: false,
+					PATs: map[string]*server.PersonalAccessToken{
+						"1": {},
+					},
+				},
+			},
+		},
+	}
+}
+
+// TestGenerateProperties tests and validate the properties generation by using the mockDatasource for the Worker.generateProperties
+func TestGenerateProperties(t *testing.T) {
+	ds := mockDatasource{}
+	worker := Worker{
+		dataSource:  ds,
+		connManager: ds,
+	}
+
+	properties := worker.generateProperties()
+
+	if properties["accounts"] != 2 {
+		t.Errorf("expected 2 accounts, got %d", properties["accounts"])
+	}
+	if properties["peers"] != 2 {
+		t.Errorf("expected 2 peers, got %d", properties["peers"])
+	}
+	if properties["routes"] != 2 {
+		t.Errorf("expected 2 routes, got %d", properties["routes"])
+	}
+	if properties["rules"] != 4 {
+		t.Errorf("expected 4 rules, got %d", properties["rules"])
+	}
+	if properties["users"] != 2 {
+		t.Errorf("expected 1 users, got %d", properties["users"])
+	}
+	if properties["setup_keys_usage"] != 2 {
+		t.Errorf("expected 1 setup_keys_usage, got %d", properties["setup_keys_usage"])
+	}
+	if properties["pats"] != 4 {
+		t.Errorf("expected 4 personal_access_tokens, got %d", properties["pats"])
+	}
+	if properties["peers_ssh_enabled"] != 2 {
+		t.Errorf("expected 2 peers_ssh_enabled, got %d", properties["peers_ssh_enabled"])
+	}
+	if properties["routes_with_routing_groups"] != 2 {
+		t.Errorf("expected 2 routes_with_routing_groups, got %d", properties["routes_with_routing_groups"])
+	}
+	if properties["rules_protocol_tcp"] != 4 {
+		t.Errorf("expected 4 rules_protocol_tcp, got %d", properties["rules_protocol_tcp"])
+	}
+	if properties["rules_direction_oneway"] != 2 {
+		t.Errorf("expected 2 rules_direction_oneway, got %d", properties["rules_direction_oneway"])
+	}
+
+	if properties["active_peers_last_day"] != 2 {
+		t.Errorf("expected 2 active_peers_last_day, got %d", properties["active_peers_last_day"])
+	}
+	if properties["min_active_peer_version"] != "0.0.1" {
+		t.Errorf("expected 0.0.1 min_active_peer_version, got %s", properties["min_active_peer_version"])
+	}
+	if properties["max_active_peer_version"] != "0.0.1" {
+		t.Errorf("expected 0.0.1 max_active_peer_version, got %s", properties["max_active_peer_version"])
+	}
+
+	if properties["peers_login_expiration_enabled"] != 2 {
+		t.Errorf("expected 2 peers_login_expiration_enabled, got %d", properties["peers_login_expiration_enabled"])
+	}
+
+	if properties["service_users"] != 2 {
+		t.Errorf("expected 2 service_users, got %d", properties["service_users"])
+	}
+
+	if properties["peer_os_linux"] != 2 {
+		t.Errorf("expected 2 peer_os_linux, got %d", properties["peer_os_linux"])
+	}
+
+	if properties["ephemeral_peers_setup_keys"] != 2 {
+		t.Errorf("expected 2 ephemeral_peers_setup_keys, got %d", properties["ephemeral_peers_setup_keys_usage"])
+	}
+
+	if properties["ephemeral_peers_setup_keys_usage"] != 2 {
+		t.Errorf("expected 2 ephemeral_peers_setup_keys_usage, got %d", properties["ephemeral_peers_setup_keys_usage"])
+	}
+
+	if properties["nameservers"] != 2 {
+		t.Errorf("expected 2 nameservers, got %d", properties["nameservers"])
+	}
+
+	if properties["groups"] != 4 {
+		t.Errorf("expected 4 groups, got %d", properties["groups"])
+	}
+
+	if properties["user_peers"] != 2 {
+		t.Errorf("expected 2 user_peers, got %d", properties["user_peers"])
+	}
+}
diff --git a/management/server/route_test.go b/management/server/route_test.go
index 81ce21a3..32f15843 100644
--- a/management/server/route_test.go
+++ b/management/server/route_test.go
@@ -14,24 +14,37 @@ import (
 const (
 	peer1Key           = "BhRPtynAAYRDy08+q4HTMsos8fs4plTP4NOSh7C1ry8="
 	peer2Key           = "/yF0+vCfv+mRR5k0dca0TrGdO/oiNeAI58gToZm5NyI="
+	peer3Key           = "ayF0+vCfv+mRR5k0dca0TrGdO/oiNeAI58gToZm5NaF="
+	peer4Key           = "ayF0+vCfv+mRR5k0dca0TrGdO/oiNeAI58gToZm5acc="
+	peer5Key           = "ayF0+vCfv+mRR5k0dca0TrGdO/oiNeAI58gToZm5a55="
 	peer1ID            = "peer-1-id"
 	peer2ID            = "peer-2-id"
+	peer3ID            = "peer-3-id"
+	peer4ID            = "peer-4-id"
+	peer5ID            = "peer-5-id"
 	routeGroup1        = "routeGroup1"
 	routeGroup2        = "routeGroup2"
+	routeGroup3        = "routeGroup3" // for existing route
+	routeGroup4        = "routeGroup4" // for existing route
+	routeGroupHA1      = "routeGroupHA1"
+	routeGroupHA2      = "routeGroupHA2"
 	routeInvalidGroup1 = "routeInvalidGroup1"
 	userID             = "testingUser"
+	existingNetwork    = "10.10.10.0/24"
+	existingRouteID    = "random-id"
 )
 
 func TestCreateRoute(t *testing.T) {
 	type input struct {
-		network     string
-		netID       string
-		peerKey     string
-		description string
-		masquerade  bool
-		metric      int
-		enabled     bool
-		groups      []string
+		network      string
+		netID        string
+		peerKey      string
+		peerGroupIDs []string
+		description  string
+		masquerade   bool
+		metric       int
+		enabled      bool
+		groups       []string
 	}
 
 	testCases := []struct {
@@ -67,6 +80,48 @@ func TestCreateRoute(t *testing.T) {
 				Groups:      []string{routeGroup1},
 			},
 		},
+		{
+			name: "Happy Path Peer Groups",
+			inputArgs: input{
+				network:      "192.168.0.0/16",
+				netID:        "happy",
+				peerGroupIDs: []string{routeGroupHA1, routeGroupHA2},
+				description:  "super",
+				masquerade:   false,
+				metric:       9999,
+				enabled:      true,
+				groups:       []string{routeGroup1, routeGroup2},
+			},
+			errFunc:      require.NoError,
+			shouldCreate: true,
+			expectedRoute: &route.Route{
+				Network:     netip.MustParsePrefix("192.168.0.0/16"),
+				NetworkType: route.IPv4Network,
+				NetID:       "happy",
+				PeerGroups:  []string{routeGroupHA1, routeGroupHA2},
+				Description: "super",
+				Masquerade:  false,
+				Metric:      9999,
+				Enabled:     true,
+				Groups:      []string{routeGroup1, routeGroup2},
+			},
+		},
+		{
+			name: "Both peer and peer_groups Provided Should Fail",
+			inputArgs: input{
+				network:      "192.168.0.0/16",
+				netID:        "happy",
+				peerKey:      peer1ID,
+				peerGroupIDs: []string{routeGroupHA1},
+				description:  "super",
+				masquerade:   false,
+				metric:       9999,
+				enabled:      true,
+				groups:       []string{routeGroup1},
+			},
+			errFunc:      require.Error,
+			shouldCreate: false,
+		},
 		{
 			name: "Bad Prefix Should Fail",
 			inputArgs: input{
@@ -97,6 +152,36 @@ func TestCreateRoute(t *testing.T) {
 			errFunc:      require.Error,
 			shouldCreate: false,
 		},
+		{
+			name: "Bad Peer already has this route",
+			inputArgs: input{
+				network:     existingNetwork,
+				netID:       "bad",
+				peerKey:     peer5ID,
+				description: "super",
+				masquerade:  false,
+				metric:      9999,
+				enabled:     true,
+				groups:      []string{routeGroup1},
+			},
+			errFunc:      require.Error,
+			shouldCreate: false,
+		},
+		{
+			name: "Bad Peers Group already has this route",
+			inputArgs: input{
+				network:      existingNetwork,
+				netID:        "bad",
+				peerGroupIDs: []string{routeGroup1, routeGroup3},
+				description:  "super",
+				masquerade:   false,
+				metric:       9999,
+				enabled:      true,
+				groups:       []string{routeGroup1},
+			},
+			errFunc:      require.Error,
+			shouldCreate: false,
+		},
 		{
 			name: "Empty Peer Should Create",
 			inputArgs: input{
@@ -238,13 +323,14 @@ func TestCreateRoute(t *testing.T) {
 
 			account, err := initTestRouteAccount(t, am)
 			if err != nil {
-				t.Error("failed to init testing account")
+				t.Errorf("failed to init testing account: %s", err)
 			}
 
 			outRoute, err := am.CreateRoute(
 				account.Id,
 				testCase.inputArgs.network,
 				testCase.inputArgs.peerKey,
+				testCase.inputArgs.peerGroupIDs,
 				testCase.inputArgs.description,
 				testCase.inputArgs.netID,
 				testCase.inputArgs.masquerade,
@@ -272,6 +358,7 @@ func TestCreateRoute(t *testing.T) {
 
 func TestSaveRoute(t *testing.T) {
 	validPeer := peer2ID
+	validUsedPeer := peer5ID
 	invalidPeer := "nonExisting"
 	validPrefix := netip.MustParsePrefix("192.168.0.0/24")
 	invalidPrefix, _ := netip.ParsePrefix("192.168.0.0/34")
@@ -279,11 +366,14 @@ func TestSaveRoute(t *testing.T) {
 	invalidMetric := 99999
 	validNetID := "12345678901234567890qw"
 	invalidNetID := "12345678901234567890qwertyuiopqwertyuiop1"
+	validGroupHA1 := routeGroupHA1
+	validGroupHA2 := routeGroupHA2
 
 	testCases := []struct {
 		name          string
 		existingRoute *route.Route
 		newPeer       *string
+		newPeerGroups []string
 		newMetric     *int
 		newPrefix     *netip.Prefix
 		newGroups     []string
@@ -325,6 +415,55 @@ func TestSaveRoute(t *testing.T) {
 				Groups:      []string{routeGroup2},
 			},
 		},
+		{
+			name: "Happy Path Peer Groups",
+			existingRoute: &route.Route{
+				ID:          "testingRoute",
+				Network:     netip.MustParsePrefix("192.168.0.0/16"),
+				NetID:       validNetID,
+				NetworkType: route.IPv4Network,
+				Description: "super",
+				Masquerade:  false,
+				Metric:      9999,
+				Enabled:     true,
+				Groups:      []string{routeGroup1},
+			},
+			newPeerGroups: []string{validGroupHA1, validGroupHA2},
+			newMetric:     &validMetric,
+			newPrefix:     &validPrefix,
+			newGroups:     []string{routeGroup2},
+			errFunc:       require.NoError,
+			shouldCreate:  true,
+			expectedRoute: &route.Route{
+				ID:          "testingRoute",
+				Network:     validPrefix,
+				NetID:       validNetID,
+				NetworkType: route.IPv4Network,
+				PeerGroups:  []string{validGroupHA1, validGroupHA2},
+				Description: "super",
+				Masquerade:  false,
+				Metric:      validMetric,
+				Enabled:     true,
+				Groups:      []string{routeGroup2},
+			},
+		},
+		{
+			name: "Both peer and peers_roup Provided Should Fail",
+			existingRoute: &route.Route{
+				ID:          "testingRoute",
+				Network:     netip.MustParsePrefix("192.168.0.0/16"),
+				NetID:       validNetID,
+				NetworkType: route.IPv4Network,
+				Description: "super",
+				Masquerade:  false,
+				Metric:      9999,
+				Enabled:     true,
+				Groups:      []string{routeGroup1},
+			},
+			newPeer:       &validPeer,
+			newPeerGroups: []string{validGroupHA1},
+			errFunc:       require.Error,
+		},
 		{
 			name: "Bad Prefix Should Fail",
 			existingRoute: &route.Route{
@@ -461,6 +600,71 @@ func TestSaveRoute(t *testing.T) {
 			newGroups: []string{routeInvalidGroup1},
 			errFunc:   require.Error,
 		},
+		{
+			name: "Allow to modify existing route with new peer",
+			existingRoute: &route.Route{
+				ID:          "testingRoute",
+				Network:     netip.MustParsePrefix(existingNetwork),
+				NetID:       validNetID,
+				NetworkType: route.IPv4Network,
+				Peer:        peer1ID,
+				Description: "super",
+				Masquerade:  false,
+				Metric:      9999,
+				Enabled:     true,
+				Groups:      []string{routeGroup1},
+			},
+			newPeer:      &validPeer,
+			errFunc:      require.NoError,
+			shouldCreate: true,
+			expectedRoute: &route.Route{
+				ID:          "testingRoute",
+				Network:     netip.MustParsePrefix(existingNetwork),
+				NetID:       validNetID,
+				NetworkType: route.IPv4Network,
+				Peer:        validPeer,
+				PeerGroups:  []string{},
+				Description: "super",
+				Masquerade:  false,
+				Metric:      9999,
+				Enabled:     true,
+				Groups:      []string{routeGroup1},
+			},
+		},
+		{
+			name: "Do not allow to modify existing route with a peer from another route",
+			existingRoute: &route.Route{
+				ID:          "testingRoute",
+				Network:     netip.MustParsePrefix(existingNetwork),
+				NetID:       validNetID,
+				NetworkType: route.IPv4Network,
+				Peer:        peer1ID,
+				Description: "super",
+				Masquerade:  false,
+				Metric:      9999,
+				Enabled:     true,
+				Groups:      []string{routeGroup1},
+			},
+			newPeer: &validUsedPeer,
+			errFunc: require.Error,
+		},
+		{
+			name: "Do not allow to modify existing route with a peers group from another route",
+			existingRoute: &route.Route{
+				ID:          "testingRoute",
+				Network:     netip.MustParsePrefix(existingNetwork),
+				NetID:       validNetID,
+				NetworkType: route.IPv4Network,
+				PeerGroups:  []string{routeGroup3},
+				Description: "super",
+				Masquerade:  false,
+				Metric:      9999,
+				Enabled:     true,
+				Groups:      []string{routeGroup1},
+			},
+			newPeerGroups: []string{routeGroup4},
+			errFunc:       require.Error,
+		},
 	}
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
@@ -488,6 +692,9 @@ func TestSaveRoute(t *testing.T) {
 				if testCase.newPeer != nil {
 					routeToSave.Peer = *testCase.newPeer
 				}
+				if len(testCase.newPeerGroups) != 0 {
+					routeToSave.PeerGroups = testCase.newPeerGroups
+				}
 				if testCase.newMetric != nil {
 					routeToSave.Metric = *testCase.newMetric
 				}
@@ -569,6 +776,96 @@ func TestDeleteRoute(t *testing.T) {
 	}
 }
 
+func TestGetNetworkMap_RouteSyncPeerGroups(t *testing.T) {
+	baseRoute := &route.Route{
+		Network:     netip.MustParsePrefix("192.168.0.0/16"),
+		NetID:       "superNet",
+		NetworkType: route.IPv4Network,
+		PeerGroups:  []string{routeGroupHA1, routeGroupHA2},
+		Description: "ha route",
+		Masquerade:  false,
+		Metric:      9999,
+		Enabled:     true,
+		Groups:      []string{routeGroup1, routeGroup2},
+	}
+
+	am, err := createRouterManager(t)
+	if err != nil {
+		t.Error("failed to create account manager")
+	}
+
+	account, err := initTestRouteAccount(t, am)
+	if err != nil {
+		t.Error("failed to init testing account")
+	}
+
+	newAccountRoutes, err := am.GetNetworkMap(peer1ID)
+	require.NoError(t, err)
+	require.Len(t, newAccountRoutes.Routes, 0, "new accounts should have no routes")
+
+	newRoute, err := am.CreateRoute(
+		account.Id, baseRoute.Network.String(), baseRoute.Peer, baseRoute.PeerGroups, baseRoute.Description,
+		baseRoute.NetID, baseRoute.Masquerade, baseRoute.Metric, baseRoute.Groups, baseRoute.Enabled, userID)
+	require.NoError(t, err)
+	require.Equal(t, newRoute.Enabled, true)
+
+	peer1Routes, err := am.GetNetworkMap(peer1ID)
+	require.NoError(t, err)
+	require.Len(t, peer1Routes.Routes, 3, "HA route should have more than 1 routes")
+
+	peer2Routes, err := am.GetNetworkMap(peer2ID)
+	require.NoError(t, err)
+	require.Len(t, peer2Routes.Routes, 3, "HA route should have more than 1 routes")
+
+	peer4Routes, err := am.GetNetworkMap(peer4ID)
+	require.NoError(t, err)
+	require.Len(t, peer4Routes.Routes, 3, "HA route should have more than 1 routes")
+
+	groups, err := am.ListGroups(account.Id)
+	require.NoError(t, err)
+	var groupHA1, groupHA2 *Group
+	for _, group := range groups {
+		switch group.Name {
+		case routeGroupHA1:
+			groupHA1 = group
+		case routeGroupHA2:
+			groupHA2 = group
+		}
+	}
+
+	err = am.GroupDeletePeer(account.Id, groupHA1.ID, peer2ID)
+	require.NoError(t, err)
+
+	peer2RoutesAfterDelete, err := am.GetNetworkMap(peer2ID)
+	require.NoError(t, err)
+	require.Len(t, peer2RoutesAfterDelete.Routes, 2, "after peer deletion group should have only 2 route")
+
+	err = am.GroupDeletePeer(account.Id, groupHA2.ID, peer4ID)
+	require.NoError(t, err)
+
+	peer2RoutesAfterDelete, err = am.GetNetworkMap(peer2ID)
+	require.NoError(t, err)
+	require.Len(t, peer2RoutesAfterDelete.Routes, 1, "after peer deletion group should have only 1 route")
+
+	err = am.GroupAddPeer(account.Id, groupHA2.ID, peer4ID)
+	require.NoError(t, err)
+
+	peer1RoutesAfterAdd, err := am.GetNetworkMap(peer1ID)
+	require.NoError(t, err)
+	require.Len(t, peer1RoutesAfterAdd.Routes, 2, "HA route should have more than 1 route")
+
+	peer2RoutesAfterAdd, err := am.GetNetworkMap(peer2ID)
+	require.NoError(t, err)
+	require.Len(t, peer2RoutesAfterAdd.Routes, 2, "HA route should have more than 1 route")
+
+	err = am.DeleteRoute(account.Id, newRoute.ID, userID)
+	require.NoError(t, err)
+
+	peer1DeletedRoute, err := am.GetNetworkMap(peer1ID)
+	require.NoError(t, err)
+	require.Len(t, peer1DeletedRoute.Routes, 0, "we should receive one route for peer1")
+}
+
 func TestGetNetworkMap_RouteSync(t *testing.T) {
 	// no routes for peer in different groups
 	// no routes when route is deleted
@@ -599,7 +896,7 @@ func TestGetNetworkMap_RouteSync(t *testing.T) {
 	require.NoError(t, err)
 	require.Len(t, newAccountRoutes.Routes, 0, "new accounts should have no routes")
 
-	createdRoute, err := am.CreateRoute(account.Id, baseRoute.Network.String(), peer1ID,
+	createdRoute, err := am.CreateRoute(account.Id, baseRoute.Network.String(), peer1ID, []string{},
 		baseRoute.Description, baseRoute.NetID, baseRoute.Masquerade, baseRoute.Metric, baseRoute.Groups, false,
 		userID)
 	require.NoError(t, err)
@@ -695,6 +992,8 @@ func createRouterStore(t *testing.T) (Store, error) {
 }
 
 func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*Account, error) {
+	t.Helper()
+
 	accountID := "testingAcc"
 	domain := "example.com"
 
@@ -754,6 +1053,81 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*Account, er
 	}
 	account.Peers[peer2.ID] = peer2
 
+	ips = account.getTakenIPs()
+	peer3IP, err := AllocatePeerIP(account.Network.Net, ips)
+	if err != nil {
+		return nil, err
+	}
+
+	peer3 := &Peer{
+		IP:     peer3IP,
+		ID:     peer3ID,
+		Key:    peer3Key,
+		Name:   "test-host3@netbird.io",
+		UserID: userID,
+		Meta: PeerSystemMeta{
+			Hostname:  "test-host3@netbird.io",
+			GoOS:      "darwin",
+			Kernel:    "Darwin",
+			Core:      "13.4.1",
+			Platform:  "arm64",
+			OS:        "darwin",
+			WtVersion: "development",
+			UIVersion: "development",
+		},
+	}
+	account.Peers[peer3.ID] = peer3
+
+	ips = account.getTakenIPs()
+	peer4IP, err := AllocatePeerIP(account.Network.Net, ips)
+	if err != nil {
+		return nil, err
+	}
+
+	peer4 := &Peer{
+		IP:     peer4IP,
+		ID:     peer4ID,
+		Key:    peer4Key,
+		Name:   "test-host4@netbird.io",
+		UserID: userID,
+		Meta: PeerSystemMeta{
+			Hostname:  "test-host4@netbird.io",
+			GoOS:      "linux",
+			Kernel:    "Linux",
+			Core:      "21.04",
+			Platform:  "x86_64",
+			OS:        "Ubuntu",
+			WtVersion: "development",
+			UIVersion: "development",
+		},
+	}
+	account.Peers[peer4.ID] = peer4
+
+	ips = account.getTakenIPs()
+	peer5IP, err := AllocatePeerIP(account.Network.Net, ips)
+	if err != nil {
+		return nil, err
+	}
+
+	peer5 := &Peer{
+		IP:     peer5IP,
+		ID:     peer5ID,
+		Key:    peer5Key,
+		Name:   "test-host4@netbird.io",
+		UserID: userID,
+		Meta: PeerSystemMeta{
+			Hostname:  "test-host4@netbird.io",
+			GoOS:      "linux",
+			Kernel:    "Linux",
+			Core:      "21.04",
+			Platform:  "x86_64",
+			OS:        "Ubuntu",
+			WtVersion: "development",
+			UIVersion: "development",
+		},
+	}
+	account.Peers[peer5.ID] = peer5
+
 	err = am.Store.SaveAccount(account)
 	if err != nil {
 		return nil, err
@@ -770,24 +1144,57 @@ func initTestRouteAccount(t *testing.T, am *DefaultAccountManager) (*Account, er
 	if err != nil {
 		return nil, err
 	}
-
-	newGroup := &Group{
-		ID:    routeGroup1,
-		Name:  routeGroup1,
-		Peers: []string{peer1.ID},
+	err = am.GroupAddPeer(accountID, groupAll.ID, peer3ID)
+	if err != nil {
+		return nil, err
 	}
-	err = am.SaveGroup(accountID, userID, newGroup)
+	err = am.GroupAddPeer(accountID, groupAll.ID, peer4ID)
 	if err != nil {
 		return nil, err
 	}
 
-	newGroup = &Group{
-		ID:    routeGroup2,
-		Name:  routeGroup2,
-		Peers: []string{peer2.ID},
+	newGroup := []*Group{
+		{
+			ID:    routeGroup1,
+			Name:  routeGroup1,
+			Peers: []string{peer1.ID},
+		},
+		{
+			ID:    routeGroup2,
+			Name:  routeGroup2,
+			Peers: []string{peer2.ID},
+		},
+		{
+			ID:    routeGroup3,
+			Name:  routeGroup3,
+			Peers: []string{peer5.ID},
+		},
+		{
+			ID:    routeGroup4,
+			Name:  routeGroup4,
+			Peers: []string{peer5.ID},
+		},
+		{
+			ID:    routeGroupHA1,
+			Name:  routeGroupHA1,
+			Peers: []string{peer1.ID, peer2.ID, peer3.ID}, // we have one non Linux peer, see peer3
+		},
+		{
+			ID:    routeGroupHA2,
+			Name:  routeGroupHA2,
+			Peers: []string{peer1.ID, peer4.ID},
+		},
+	}
+
+	for _, group := range newGroup {
+		err = am.SaveGroup(accountID, userID, group)
+		if err != nil {
+			return nil, err
+		}
 	}
 
-	err = am.SaveGroup(accountID, userID, newGroup)
+	_, err = am.CreateRoute(account.Id, existingNetwork, "", []string{routeGroup3, routeGroup4},
+		"", existingRouteID, false, 1000, []string{groupAll.ID}, true, userID)
 	if err != nil {
 		return nil, err
 	}