Przeglądaj źródła

fix merge conflicts

abhishek9686 2 tygodni temu
rodzic
commit
d07b10a54a

+ 1 - 1
.github/workflows/branchtest.yml

@@ -41,7 +41,7 @@ jobs:
             echo "NETCLIENT_BRANCH=develop" >> $GITHUB_ENV
           fi
       - name: Checkout netclient repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
           repository: gravitl/netclient
           fetch-depth: 0

+ 1 - 1
.github/workflows/docker-builder.yml

@@ -12,7 +12,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
     - name: Checkout
-      uses: actions/checkout@v4
+      uses: actions/checkout@v5
     - name: SetUp Buildx
       uses: docker/setup-buildx-action@v3
     - name: Login to Dockerhub

+ 1 - 1
.github/workflows/docs.yml

@@ -13,7 +13,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
         with:
           repository: gravitl/netmaker
           ref: ${{ github.event.inputs.branch || 'master' }}

+ 2 - 2
.github/workflows/publish-docker.yml

@@ -29,7 +29,7 @@ jobs:
             echo "TAG=${TAG}" >> $GITHUB_ENV
       -
         name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
       -
         name: Set up QEMU
         uses: docker/setup-qemu-action@v3
@@ -69,7 +69,7 @@ jobs:
             echo "TAG=${TAG}" >> $GITHUB_ENV
       -
         name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
       -
         name: Set up QEMU
         uses: docker/setup-qemu-action@v3

+ 4 - 4
.github/workflows/test.yml

@@ -11,7 +11,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
       - name: Setup Go
         uses: actions/setup-go@v5
         with:
@@ -25,7 +25,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
       - name: Setup go
         uses: actions/setup-go@v5
         with:
@@ -42,7 +42,7 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
       - name: Setup Go
         uses: actions/setup-go@v5
         with:
@@ -62,7 +62,7 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v5
       - name: Setup Go
         uses: actions/setup-go@v5
         with:

+ 274 - 0
controllers/dns.go

@@ -1,19 +1,25 @@
 package controller
 
 import (
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"net/http"
 	"strings"
+	"time"
 
+	"github.com/google/uuid"
 	"github.com/gorilla/mux"
 	"github.com/gravitl/netmaker/database"
+	"github.com/gravitl/netmaker/db"
 	"github.com/gravitl/netmaker/logger"
 	"github.com/gravitl/netmaker/logic"
 	"github.com/gravitl/netmaker/models"
 	"github.com/gravitl/netmaker/mq"
+	"github.com/gravitl/netmaker/schema"
 	"github.com/gravitl/netmaker/servercfg"
+	"gorm.io/datatypes"
 )
 
 func dnsHandlers(r *mux.Router) {
@@ -34,6 +40,274 @@ func dnsHandlers(r *mux.Router) {
 		Methods(http.MethodPost)
 	r.HandleFunc("/api/dns/{network}/{domain}", logic.SecurityCheck(true, http.HandlerFunc(deleteDNS))).
 		Methods(http.MethodDelete)
+	r.HandleFunc("/api/v1/nameserver", logic.SecurityCheck(true, http.HandlerFunc(createNs))).Methods(http.MethodPost)
+	r.HandleFunc("/api/v1/nameserver", logic.SecurityCheck(true, http.HandlerFunc(listNs))).Methods(http.MethodGet)
+	r.HandleFunc("/api/v1/nameserver", logic.SecurityCheck(true, http.HandlerFunc(updateNs))).Methods(http.MethodPut)
+	r.HandleFunc("/api/v1/nameserver", logic.SecurityCheck(true, http.HandlerFunc(deleteNs))).Methods(http.MethodDelete)
+	r.HandleFunc("/api/v1/nameserver/global", logic.SecurityCheck(true, http.HandlerFunc(getGlobalNs))).Methods(http.MethodGet)
+}
+
+// @Summary     List Global Nameservers
+// @Router      /api/v1/nameserver/global [get]
+// @Tags        Auth
+// @Accept      json
+// @Param       query network string
+// @Success     200 {object} models.SuccessResponse
+// @Failure     400 {object} models.ErrorResponse
+// @Failure     401 {object} models.ErrorResponse
+// @Failure     500 {object} models.ErrorResponse
+func getGlobalNs(w http.ResponseWriter, r *http.Request) {
+
+	logic.ReturnSuccessResponseWithJson(w, r, logic.GlobalNsList, "fetched nameservers")
+}
+
+// @Summary     Create Nameserver
+// @Router      /api/v1/nameserver [post]
+// @Tags        DNS
+// @Accept      json
+// @Param       body body models.NameserverReq
+// @Success     200 {object} models.SuccessResponse
+// @Failure     400 {object} models.ErrorResponse
+// @Failure     401 {object} models.ErrorResponse
+// @Failure     500 {object} models.ErrorResponse
+func createNs(w http.ResponseWriter, r *http.Request) {
+
+	var req schema.Nameserver
+	err := json.NewDecoder(r.Body).Decode(&req)
+	if err != nil {
+		logger.Log(0, "error decoding request body: ",
+			err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+	if err := logic.ValidateNameserverReq(req); err != nil {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+	if req.Tags == nil {
+		req.Tags = make(datatypes.JSONMap)
+	}
+	if gNs, ok := logic.GlobalNsList[req.Name]; ok {
+		req.Servers = gNs.IPs
+	}
+	if !servercfg.IsPro {
+		req.Tags = datatypes.JSONMap{
+			"*": struct{}{},
+		}
+	}
+	if req.MatchAll {
+		req.MatchDomains = []string{"."}
+	}
+	ns := schema.Nameserver{
+		ID:           uuid.New().String(),
+		Name:         req.Name,
+		NetworkID:    req.NetworkID,
+		Description:  req.Description,
+		MatchAll:     req.MatchAll,
+		MatchDomains: req.MatchDomains,
+		Servers:      req.Servers,
+		Tags:         req.Tags,
+		Status:       true,
+		CreatedBy:    r.Header.Get("user"),
+		CreatedAt:    time.Now().UTC(),
+	}
+
+	err = ns.Create(db.WithContext(r.Context()))
+	if err != nil {
+		logic.ReturnErrorResponse(
+			w,
+			r,
+			logic.FormatError(errors.New("error creating nameserver "+err.Error()), logic.Internal),
+		)
+		return
+	}
+	logic.LogEvent(&models.Event{
+		Action: models.Create,
+		Source: models.Subject{
+			ID:   r.Header.Get("user"),
+			Name: r.Header.Get("user"),
+			Type: models.UserSub,
+		},
+		TriggeredBy: r.Header.Get("user"),
+		Target: models.Subject{
+			ID:   ns.ID,
+			Name: ns.Name,
+			Type: models.NameserverSub,
+		},
+		NetworkID: models.NetworkID(ns.NetworkID),
+		Origin:    models.Dashboard,
+	})
+
+	go mq.PublishPeerUpdate(false)
+	logic.ReturnSuccessResponseWithJson(w, r, ns, "created nameserver")
+}
+
+// @Summary     List Nameservers
+// @Router      /api/v1/nameserver [get]
+// @Tags        Auth
+// @Accept      json
+// @Param       query network string
+// @Success     200 {object} models.SuccessResponse
+// @Failure     400 {object} models.ErrorResponse
+// @Failure     401 {object} models.ErrorResponse
+// @Failure     500 {object} models.ErrorResponse
+func listNs(w http.ResponseWriter, r *http.Request) {
+
+	network := r.URL.Query().Get("network")
+	if network == "" {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("network is required"), "badrequest"))
+		return
+	}
+	ns := schema.Nameserver{NetworkID: network}
+	list, err := ns.ListByNetwork(db.WithContext(r.Context()))
+	if err != nil {
+		logic.ReturnErrorResponse(
+			w,
+			r,
+			logic.FormatError(errors.New("error listing nameservers "+err.Error()), "internal"),
+		)
+		return
+	}
+	logic.ReturnSuccessResponseWithJson(w, r, list, "fetched nameservers")
+}
+
+// @Summary     Update Nameserver
+// @Router      /api/v1/nameserver [put]
+// @Tags        Auth
+// @Accept      json
+// @Param       body body models.NameserverReq
+// @Success     200 {object} models.SuccessResponse
+// @Failure     400 {object} models.ErrorResponse
+// @Failure     401 {object} models.ErrorResponse
+// @Failure     500 {object} models.ErrorResponse
+func updateNs(w http.ResponseWriter, r *http.Request) {
+
+	var updateNs schema.Nameserver
+	err := json.NewDecoder(r.Body).Decode(&updateNs)
+	if err != nil {
+		logger.Log(0, "error decoding request body: ",
+			err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+
+	if err := logic.ValidateNameserverReq(updateNs); err != nil {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+	if updateNs.Tags == nil {
+		updateNs.Tags = make(datatypes.JSONMap)
+	}
+
+	ns := schema.Nameserver{ID: updateNs.ID}
+	err = ns.Get(db.WithContext(r.Context()))
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+		return
+	}
+	var updateStatus bool
+	var updateMatchAll bool
+	if updateNs.Status != ns.Status {
+		updateStatus = true
+	}
+	if updateNs.MatchAll != ns.MatchAll {
+		updateMatchAll = true
+	}
+	event := &models.Event{
+		Action: models.Update,
+		Source: models.Subject{
+			ID:   r.Header.Get("user"),
+			Name: r.Header.Get("user"),
+			Type: models.UserSub,
+		},
+		TriggeredBy: r.Header.Get("user"),
+		Target: models.Subject{
+			ID:   ns.ID,
+			Name: updateNs.Name,
+			Type: models.NameserverSub,
+		},
+		Diff: models.Diff{
+			Old: ns,
+			New: updateNs,
+		},
+		NetworkID: models.NetworkID(ns.NetworkID),
+		Origin:    models.Dashboard,
+	}
+	ns.Servers = updateNs.Servers
+	ns.Tags = updateNs.Tags
+	ns.MatchDomains = updateNs.MatchDomains
+	ns.MatchAll = updateNs.MatchAll
+	ns.Description = updateNs.Description
+	ns.Name = updateNs.Name
+	ns.Status = updateNs.Status
+	ns.UpdatedAt = time.Now().UTC()
+
+	err = ns.Update(db.WithContext(context.TODO()))
+	if err != nil {
+		logic.ReturnErrorResponse(
+			w,
+			r,
+			logic.FormatError(errors.New("error creating egress resource"+err.Error()), "internal"),
+		)
+		return
+	}
+	if updateStatus {
+		ns.UpdateStatus(db.WithContext(context.TODO()))
+	}
+	if updateMatchAll {
+		ns.UpdateMatchAll(db.WithContext(context.TODO()))
+	}
+	logic.LogEvent(event)
+	go mq.PublishPeerUpdate(false)
+	logic.ReturnSuccessResponseWithJson(w, r, ns, "updated nameserver")
+}
+
+// @Summary     Delete Nameserver Resource
+// @Router      /api/v1/nameserver [delete]
+// @Tags        Auth
+// @Accept      json
+// @Param       body body models.Egress
+// @Success     200 {object} models.SuccessResponse
+// @Failure     400 {object} models.ErrorResponse
+// @Failure     401 {object} models.ErrorResponse
+// @Failure     500 {object} models.ErrorResponse
+func deleteNs(w http.ResponseWriter, r *http.Request) {
+
+	id := r.URL.Query().Get("id")
+	if id == "" {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("id is required"), "badrequest"))
+		return
+	}
+	ns := schema.Nameserver{ID: id}
+	err := ns.Get(db.WithContext(r.Context()))
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.BadReq))
+		return
+	}
+	err = ns.Delete(db.WithContext(r.Context()))
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.Internal))
+		return
+	}
+	logic.LogEvent(&models.Event{
+		Action: models.Delete,
+		Source: models.Subject{
+			ID:   r.Header.Get("user"),
+			Name: r.Header.Get("user"),
+			Type: models.UserSub,
+		},
+		TriggeredBy: r.Header.Get("user"),
+		Target: models.Subject{
+			ID:   ns.ID,
+			Name: ns.Name,
+			Type: models.NameserverSub,
+		},
+		NetworkID: models.NetworkID(ns.NetworkID),
+		Origin:    models.Dashboard,
+	})
+
+	go mq.PublishPeerUpdate(false)
+	logic.ReturnSuccessResponseWithJson(w, r, nil, "deleted nameserver resource")
 }
 
 // @Summary     Gets node DNS entries associated with a network

+ 13 - 85
controllers/ext_client.go

@@ -133,6 +133,12 @@ func getExtClient(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 		return
 	}
+	gwNode, err := logic.GetNodeByID(client.IngressGatewayID)
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		return
+	}
+	logic.SetDNSOnWgConfig(&gwNode, &client)
 
 	w.WriteHeader(http.StatusOK)
 	json.NewEncoder(w).Encode(client)
@@ -288,39 +294,11 @@ func getExtClientConf(w http.ResponseWriter, r *http.Request) {
 			}
 		}
 	}
-
+	logic.SetDNSOnWgConfig(&gwnode, &client)
 	defaultDNS := ""
 	if client.DNS != "" {
 		defaultDNS = "DNS = " + client.DNS
-	} else if gwnode.IngressDNS != "" {
-		defaultDNS = "DNS = " + gwnode.IngressDNS
 	}
-	if client.DNS == "" {
-		if len(network.NameServers) > 0 {
-			if defaultDNS == "" {
-				defaultDNS = "DNS = " + strings.Join(network.NameServers, ",")
-			} else {
-				defaultDNS += "," + strings.Join(network.NameServers, ",")
-			}
-
-		}
-	}
-	// if servercfg.GetManageDNS() {
-	// 	if gwnode.Address6.IP != nil {
-	// 		if defaultDNS == "" {
-	// 			defaultDNS = "DNS = " + gwnode.Address6.IP.String()
-	// 		} else {
-	// 			defaultDNS = defaultDNS + ", " + gwnode.Address6.IP.String()
-	// 		}
-	// 	}
-	// 	if gwnode.Address.IP != nil {
-	// 		if defaultDNS == "" {
-	// 			defaultDNS = "DNS = " + gwnode.Address.IP.String()
-	// 		} else {
-	// 			defaultDNS = defaultDNS + ", " + gwnode.Address.IP.String()
-	// 		}
-	// 	}
-	// }
 
 	defaultMTU := 1420
 	if host.MTU != 0 {
@@ -755,18 +733,10 @@ func createExtClient(w http.ResponseWriter, r *http.Request) {
 	extclient.Tags = make(map[models.TagID]struct{})
 	// extclient.Tags[models.TagID(fmt.Sprintf("%s.%s", extclient.Network,
 	// 	models.RemoteAccessTagName))] = struct{}{}
-	// set extclient dns to ingressdns if extclient dns is not explicitly set
-	if (extclient.DNS == "") && (node.IngressDNS != "") {
-		network, _ := logic.GetNetwork(node.Network)
-		dns := node.IngressDNS
-		if len(network.NameServers) > 0 {
-			if dns == "" {
-				dns = strings.Join(network.NameServers, ",")
-			} else {
-				dns += "," + strings.Join(network.NameServers, ",")
-			}
-
-		}
+	// set extclient dns to ingressdns if extclient dns is not explicitly
+	gwDNS := logic.GetGwDNS(&node)
+	if (extclient.DNS == "") && (gwDNS != "") {
+		dns := gwDNS
 		extclient.DNS = dns
 	}
 	host, err := logic.GetHost(node.HostID.String())
@@ -879,7 +849,6 @@ func updateExtClient(w http.ResponseWriter, r *http.Request) {
 
 	var update models.CustomExtClient
 	//var oldExtClient models.ExtClient
-	var sendPeerUpdate bool
 	var replacePeers bool
 	err := json.NewDecoder(r.Body).Decode(&update)
 	if err != nil {
@@ -928,19 +897,11 @@ func updateExtClient(w http.ResponseWriter, r *http.Request) {
 	var changedID = update.ClientID != oldExtClient.ClientID
 
 	if !reflect.DeepEqual(update.DeniedACLs, oldExtClient.DeniedACLs) {
-		sendPeerUpdate = true
 		logic.SetClientACLs(&oldExtClient, update.DeniedACLs)
 	}
-	if !logic.IsSlicesEqual(update.ExtraAllowedIPs, oldExtClient.ExtraAllowedIPs) {
-		sendPeerUpdate = true
-	}
 
-	if update.Enabled != oldExtClient.Enabled {
-		sendPeerUpdate = true
-	}
 	if update.PublicKey != oldExtClient.PublicKey {
 		//remove old peer entry
-		sendPeerUpdate = true
 		replacePeers = true
 	}
 	if update.RemoteAccessClientID != "" && update.Location == "" {
@@ -985,45 +946,12 @@ func updateExtClient(w http.ResponseWriter, r *http.Request) {
 		if changedID && servercfg.IsDNSMode() {
 			logic.SetDNS()
 		}
-		if replacePeers {
+		if replacePeers || !update.Enabled {
 			if err := mq.PublishDeletedClientPeerUpdate(&oldExtClient); err != nil {
 				slog.Error("error deleting old ext peers", "error", err.Error())
 			}
 		}
-		if sendPeerUpdate { // need to send a peer update to the ingress node as enablement of one of it's clients has changed
-			ingressNode, err := logic.GetNodeByID(newclient.IngressGatewayID)
-			if err == nil {
-				if err = mq.PublishPeerUpdate(false); err != nil {
-					logger.Log(
-						1,
-						"error setting ext peers on",
-						ingressNode.ID.String(),
-						":",
-						err.Error(),
-					)
-				}
-			}
-			if !update.Enabled {
-				ingressHost, err := logic.GetHost(ingressNode.HostID.String())
-				if err != nil {
-					slog.Error(
-						"Failed to get ingress host",
-						"node",
-						ingressNode.ID.String(),
-						"error",
-						err,
-					)
-					return
-				}
-				nodes, err := logic.GetAllNodes()
-				if err != nil {
-					slog.Error("Failed to get nodes", "error", err)
-					return
-				}
-				go mq.PublishSingleHostPeerUpdate(ingressHost, nodes, nil, []models.ExtClient{oldExtClient}, false, nil)
-			}
-		}
-
+		mq.PublishPeerUpdate(false)
 	}()
 
 }

+ 1 - 0
controllers/hosts.go

@@ -256,6 +256,7 @@ func pull(w http.ResponseWriter, r *http.Request) {
 		NameServers:       hPU.NameServers,
 		EgressWithDomains: hPU.EgressWithDomains,
 		EndpointDetection: logic.IsEndpointDetectionEnabled(),
+		DnsNameservers:    hPU.DnsNameservers,
 	}
 
 	logger.Log(1, hostID, "completed a pull")

+ 19 - 0
controllers/server.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
+	"fmt"
 	"github.com/gravitl/netmaker/db"
 	"github.com/gravitl/netmaker/schema"
 	"github.com/google/go-cmp/cmp"
@@ -274,6 +275,24 @@ func updateSettings(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	currSettings := logic.GetServerSettings()
+
+	if req.AuthProvider != currSettings.AuthProvider && req.AuthProvider == "" {
+		superAdmin, err := logic.GetSuperAdmin()
+		if err != nil {
+			err = fmt.Errorf("failed to get super admin: %v", err)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+			return
+		}
+
+		if superAdmin.AuthType == models.OAuth {
+			err := fmt.Errorf(
+				"cannot remove IdP integration because an OAuth user has the super-admin role; transfer the super-admin role to another user first",
+			)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+			return
+		}
+	}
+
 	err := logic.UpsertServerSettings(req)
 	if err != nil {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("failed to update server settings "+err.Error()), "internal"))

+ 183 - 14
controllers/user.go

@@ -6,13 +6,14 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/pquerna/otp"
-	"golang.org/x/crypto/bcrypt"
 	"image/png"
 	"net/http"
 	"reflect"
 	"time"
 
+	"github.com/pquerna/otp"
+	"golang.org/x/crypto/bcrypt"
+
 	"github.com/google/uuid"
 	"github.com/gorilla/mux"
 	"github.com/gorilla/websocket"
@@ -295,7 +296,7 @@ func authenticateUser(response http.ResponseWriter, request *http.Request) {
 		return
 	}
 
-	if !user.IsSuperAdmin && !logic.IsBasicAuthEnabled() {
+	if user.PlatformRoleID != models.SuperAdminRole && !logic.IsBasicAuthEnabled() {
 		logic.ReturnErrorResponse(
 			response,
 			request,
@@ -776,6 +777,52 @@ func enableUserAccount(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	var caller *models.User
+	var isMaster bool
+	if r.Header.Get("user") == logic.MasterUser {
+		isMaster = true
+	} else {
+		caller, err = logic.GetUser(r.Header.Get("user"))
+		if err != nil {
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+			return
+		}
+	}
+
+	if !isMaster && caller.UserName == user.UserName {
+		// This implies that a user is trying to enable themselves.
+		// This can never happen, since a disabled user cannot be
+		// authenticated.
+		err := fmt.Errorf("cannot enable self")
+		logger.Log(0, err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+		return
+	}
+
+	switch user.PlatformRoleID {
+	case models.SuperAdminRole:
+		// This can never happen, since a superadmin user cannot
+		// be disabled.
+	case models.AdminRole:
+		if !isMaster && caller.PlatformRoleID != models.SuperAdminRole {
+			err = fmt.Errorf("%s cannot enable an admin", caller.PlatformRoleID)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+			return
+		}
+	case models.PlatformUser:
+		if !isMaster && caller.PlatformRoleID != models.SuperAdminRole && caller.PlatformRoleID != models.AdminRole {
+			err = fmt.Errorf("%s cannot enable a platform-user", caller.PlatformRoleID)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+			return
+		}
+	case models.ServiceUser:
+		if !isMaster && caller.PlatformRoleID != models.SuperAdminRole && caller.PlatformRoleID != models.AdminRole {
+			err = fmt.Errorf("%s cannot enable a service-user", caller.PlatformRoleID)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+			return
+		}
+	}
+
 	user.AccountDisabled = false
 	err = logic.UpsertUser(*user)
 	if err != nil {
@@ -802,13 +849,51 @@ func disableUserAccount(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	if user.PlatformRoleID == models.SuperAdminRole {
-		err = errors.New("cannot disable super-admin user account")
-		logger.Log(0, err.Error())
-		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
+	var caller *models.User
+	var isMaster bool
+	if r.Header.Get("user") == logic.MasterUser {
+		isMaster = true
+	} else {
+		caller, err = logic.GetUser(r.Header.Get("user"))
+		if err != nil {
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+			return
+		}
+	}
+
+	if !isMaster && caller.UserName == user.UserName {
+		// This implies that a user is trying to disable themselves.
+		// This should not be allowed.
+		err = fmt.Errorf("cannot disable self")
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
 		return
 	}
 
+	switch user.PlatformRoleID {
+	case models.SuperAdminRole:
+		err = errors.New("cannot disable a super-admin")
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+		return
+	case models.AdminRole:
+		if !isMaster && caller.PlatformRoleID != models.SuperAdminRole {
+			err = fmt.Errorf("%s cannot disable an admin", caller.PlatformRoleID)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+			return
+		}
+	case models.PlatformUser:
+		if !isMaster && caller.PlatformRoleID != models.SuperAdminRole && caller.PlatformRoleID != models.AdminRole {
+			err = fmt.Errorf("%s cannot disable a platform-user", caller.PlatformRoleID)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+			return
+		}
+	case models.ServiceUser:
+		if !isMaster && caller.PlatformRoleID != models.SuperAdminRole && caller.PlatformRoleID != models.AdminRole {
+			err = fmt.Errorf("%s cannot disable a service-user", caller.PlatformRoleID)
+			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "forbidden"))
+			return
+		}
+	}
+
 	user.AccountDisabled = true
 	err = logic.UpsertUser(*user)
 	if err != nil {
@@ -816,6 +901,28 @@ func disableUserAccount(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 	}
 
+	go func() {
+		extclients, err := logic.GetAllExtClients()
+		if err != nil {
+			logger.Log(0, "failed to get user extclients:", err.Error())
+			return
+		}
+
+		for _, extclient := range extclients {
+			if extclient.OwnerID == user.UserName {
+				err = logic.DeleteExtClientAndCleanup(extclient)
+				if err != nil {
+					logger.Log(0, "failed to delete user extclient:", err.Error())
+				} else {
+					err := mq.PublishDeletedClientPeerUpdate(&extclient)
+					if err != nil {
+						logger.Log(0, "failed to publish deleted client peer update:", err.Error())
+					}
+				}
+			}
+		}
+	}()
+
 	logic.ReturnSuccessResponse(w, r, "user account disabled")
 }
 
@@ -925,18 +1032,16 @@ func getUsers(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "application/json")
 
 	users, err := logic.GetUsers()
-
-	for i := range users {
-		users[i].NumAccessTokens, _ = (&schema.UserAccessToken{
-			UserName: users[i].UserName,
-		}).CountByUser(r.Context())
-	}
-
 	if err != nil {
 		logger.Log(0, "failed to fetch users: ", err.Error())
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 		return
 	}
+	for i := range users {
+		users[i].NumAccessTokens, _ = (&schema.UserAccessToken{
+			UserName: users[i].UserName,
+		}).CountByUser(r.Context())
+	}
 
 	logic.SortUsers(users[:])
 	logger.Log(2, r.Header.Get("user"), "fetched users")
@@ -1019,6 +1124,7 @@ func transferSuperAdmin(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	u.IsSuperAdmin = true
 	u.PlatformRoleID = models.SuperAdminRole
 	err = logic.UpsertUser(*u)
 	if err != nil {
@@ -1026,6 +1132,8 @@ func transferSuperAdmin(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 		return
 	}
+
+	caller.IsSuperAdmin = false
 	caller.PlatformRoleID = models.AdminRole
 	err = logic.UpsertUser(*caller)
 	if err != nil {
@@ -1314,6 +1422,67 @@ func updateUser(w http.ResponseWriter, r *http.Request) {
 	}
 	logic.LogEvent(&e)
 	go mq.PublishPeerUpdate(false)
+	go func() {
+		// Populating all the networks the user has access to by
+		// being a member of groups.
+		userMembershipNetworkAccess := make(map[models.NetworkID]struct{})
+		for groupID := range user.UserGroups {
+			userGroup, _ := logic.GetUserGroup(groupID)
+			for netID := range userGroup.NetworkRoles {
+				userMembershipNetworkAccess[netID] = struct{}{}
+			}
+		}
+
+		extclients, err := logic.GetAllExtClients()
+		if err != nil {
+			slog.Error("failed to fetch extclients", "error", err)
+			return
+		}
+
+		for _, extclient := range extclients {
+			if extclient.OwnerID != user.UserName {
+				continue
+			}
+
+			var shouldDelete bool
+			if user.PlatformRoleID == models.SuperAdminRole || user.PlatformRoleID == models.AdminRole {
+				// Super-admin and Admin's access is not determined by group membership
+				// or network roles. Even if a user is removed from the group, they
+				// continue to have access to the network.
+				// So, no need to delete the extclient.
+				shouldDelete = false
+			} else {
+				_, hasAccess := user.NetworkRoles[models.NetworkID(extclient.Network)]
+				if hasAccess {
+					// The user has access to the network by themselves and not by
+					// virtue of being a member of the group.
+					// So, no need to delete the extclient.
+					shouldDelete = false
+				} else {
+					_, hasAccessThroughGroups := userMembershipNetworkAccess[models.NetworkID(extclient.Network)]
+					if !hasAccessThroughGroups {
+						// The user does not have access to the network by either
+						// being a Super-admin or Admin, by network roles or by virtue
+						// of being a member a group that has access to the network.
+						// So, delete the extclient.
+						shouldDelete = true
+					}
+				}
+			}
+
+			if shouldDelete {
+				err = logic.DeleteExtClientAndCleanup(extclient)
+				if err != nil {
+					slog.Error("failed to delete extclient",
+						"id", extclient.ClientID, "owner", user.UserName, "error", err)
+				} else {
+					if err := mq.PublishDeletedClientPeerUpdate(&extclient); err != nil {
+						slog.Error("error setting ext peers: " + err.Error())
+					}
+				}
+			}
+		}
+	}()
 	logger.Log(1, username, "was updated")
 	json.NewEncoder(w).Encode(logic.ToReturnUser(*user))
 }

+ 13 - 13
go.mod

@@ -13,18 +13,18 @@ require (
 	github.com/gorilla/handlers v1.5.2
 	github.com/gorilla/mux v1.8.1
 	github.com/lib/pq v1.10.9
-	github.com/mattn/go-sqlite3 v1.14.30
+	github.com/mattn/go-sqlite3 v1.14.32
 	github.com/rqlite/gorqlite v0.0.0-20240122221808-a8a425b1a6aa
 	github.com/seancfoley/ipaddress-go v1.7.1
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
-	github.com/stretchr/testify v1.10.0
+	github.com/stretchr/testify v1.11.0
 	github.com/txn2/txeh v1.5.5
 	go.uber.org/automaxprocs v1.6.0
-	golang.org/x/crypto v0.40.0
-	golang.org/x/net v0.42.0 // indirect
+	golang.org/x/crypto v0.41.0
+	golang.org/x/net v0.43.0 // indirect
 	golang.org/x/oauth2 v0.30.0
-	golang.org/x/sys v0.34.0 // indirect
-	golang.org/x/text v0.27.0 // indirect
+	golang.org/x/sys v0.35.0 // indirect
+	golang.org/x/text v0.28.0 // indirect
 	golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb
 	gopkg.in/yaml.v3 v3.0.1
 )
@@ -32,11 +32,11 @@ require (
 require (
 	filippo.io/edwards25519 v1.1.0
 	github.com/c-robinson/iplib v1.0.8
-	github.com/posthog/posthog-go v1.6.1
+	github.com/posthog/posthog-go v1.6.4
 )
 
 require (
-	github.com/coreos/go-oidc/v3 v3.14.1
+	github.com/coreos/go-oidc/v3 v3.15.0
 	github.com/gorilla/websocket v1.5.3
 	golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
 )
@@ -49,7 +49,7 @@ require (
 	github.com/okta/okta-sdk-golang/v5 v5.0.6
 	github.com/pquerna/otp v1.5.0
 	github.com/spf13/cobra v1.9.1
-	google.golang.org/api v0.244.0
+	google.golang.org/api v0.248.0
 	gopkg.in/mail.v2 v2.3.1
 	gorm.io/datatypes v1.2.6
 	gorm.io/driver/postgres v1.6.0
@@ -58,9 +58,9 @@ require (
 )
 
 require (
-	cloud.google.com/go/auth v0.16.3 // indirect
+	cloud.google.com/go/auth v0.16.5 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
-	cloud.google.com/go/compute/metadata v0.7.0 // indirect
+	cloud.google.com/go/compute/metadata v0.8.0 // indirect
 	github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
 	github.com/cenkalti/backoff/v4 v4.1.3 // indirect
 	github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
@@ -100,9 +100,9 @@ require (
 	go.opentelemetry.io/otel v1.36.0 // indirect
 	go.opentelemetry.io/otel/metric v1.36.0 // indirect
 	go.opentelemetry.io/otel/trace v1.36.0 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect
 	google.golang.org/grpc v1.74.2 // indirect
-	google.golang.org/protobuf v1.36.6 // indirect
+	google.golang.org/protobuf v1.36.7 // indirect
 	gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
 	gorm.io/driver/mysql v1.5.6 // indirect
 )

+ 26 - 26
go.sum

@@ -1,9 +1,9 @@
-cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc=
-cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA=
+cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI=
+cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ=
 cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
 cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
-cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
-cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
+cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA=
+cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw=
 filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
 filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
 github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
@@ -14,8 +14,8 @@ github.com/c-robinson/iplib v1.0.8 h1:exDRViDyL9UBLcfmlxxkY5odWX5092nPsQIykHXhIn
 github.com/c-robinson/iplib v1.0.8/go.mod h1:i3LuuFL1hRT5gFpBRnEydzw8R6yhGkF4szNDIbF8pgo=
 github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4=
 github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
-github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
-github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
+github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
+github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -128,8 +128,8 @@ github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwM
 github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/mattn/go-sqlite3 v1.14.30 h1:bVreufq3EAIG1Quvws73du3/QgdeZ3myglJlrzSYYCY=
-github.com/mattn/go-sqlite3 v1.14.30/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
 github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
 github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
 github.com/okta/okta-sdk-golang/v5 v5.0.6 h1:p7ptDMB1KxQ/7xSh+6FhMSybwl+ubTV4f1oL4N0Bu6U=
@@ -140,8 +140,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/posthog/posthog-go v1.6.1 h1:3ZspN1rTqaK3WBWwLr+rAzDMeeolmdGDT3BxzNweFrc=
-github.com/posthog/posthog-go v1.6.1/go.mod h1:ZPCind3bz8xDLK0Zhvpv1fQav6WfRcQDqTMfMXmna98=
+github.com/posthog/posthog-go v1.6.4 h1:vPo6Z8T1R+aUBugXg1+psD8qZYSOtFktzhj6H8rzOBI=
+github.com/posthog/posthog-go v1.6.4/go.mod h1:LcC1Nu4AgvV22EndTtrMXTy+7RGVC0MhChSw7Qk5XkY=
 github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
 github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
 github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
@@ -175,8 +175,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
+github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 github.com/txn2/txeh v1.5.5 h1:UN4e/lCK5HGw/gGAi2GCVrNKg0GTCUWs7gs5riaZlz4=
 github.com/txn2/txeh v1.5.5/go.mod h1:qYzGG9kCzeVEI12geK4IlanHWY8X4uy/I3NcW7mk8g4=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -202,8 +202,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
 golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
-golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
-golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
+golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
+golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -214,8 +214,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
 golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
-golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
-golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
+golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
+golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
 golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
 golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -232,8 +232,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
-golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -246,8 +246,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
-golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
+golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
+golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -257,18 +257,18 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb h1:9aqVcYEDHmSNb0uOWukxV5lHV09WqiSiCuhEgWNETLY=
 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb/go.mod h1:mQqgjkW8GQQcJQsbBvK890TKqUK1DfKWkuBGbOkuMHQ=
-google.golang.org/api v0.244.0 h1:lpkP8wVibSKr++NCD36XzTk/IzeKJ3klj7vbj+XU5pE=
-google.golang.org/api v0.244.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8=
+google.golang.org/api v0.248.0 h1:hUotakSkcwGdYUqzCRc5yGYsg4wXxpkKlW5ryVqvC1Y=
+google.golang.org/api v0.248.0/go.mod h1:yAFUAF56Li7IuIQbTFoLwXTCI6XCFKueOlS7S9e4F9k=
 google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
 google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY=
 google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1:qXWI/sQtv5UKboZ/zUk7h+mrf/lXORyI+n9DKDAusdg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
 google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
 google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
-google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
-google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
+google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

+ 4 - 17
logic/auth.go

@@ -30,25 +30,12 @@ const (
 	NetmakerDesktopApp = "netmaker-desktop"
 )
 
-var (
-	superUser = models.User{}
-)
-
-func ClearSuperUserCache() {
-	superUser = models.User{}
-}
-
 var IsOAuthConfigured = func() bool { return false }
 var ResetAuthProvider = func() {}
 var ResetIDPSyncHook = func() {}
 
 // HasSuperAdmin - checks if server has an superadmin/owner
 func HasSuperAdmin() (bool, error) {
-
-	if superUser.IsSuperAdmin {
-		return true, nil
-	}
-
 	collection, err := database.FetchRecords(database.USERS_TABLE_NAME)
 	if err != nil {
 		if database.IsEmptyRecord(err) {
@@ -63,7 +50,7 @@ func HasSuperAdmin() (bool, error) {
 		if err != nil {
 			continue
 		}
-		if user.PlatformRoleID == models.SuperAdminRole || user.IsSuperAdmin {
+		if user.PlatformRoleID == models.SuperAdminRole {
 			return true, nil
 		}
 	}
@@ -215,6 +202,8 @@ func CreateSuperAdmin(u *models.User) error {
 	if hassuperadmin {
 		return errors.New("superadmin user already exists")
 	}
+	u.IsSuperAdmin = true
+	u.IsAdmin = true
 	u.PlatformRoleID = models.SuperAdminRole
 	return CreateUser(u)
 }
@@ -282,9 +271,7 @@ func UpsertUser(user models.User) error {
 		slog.Error("error inserting user", "user", user.UserName, "error", err.Error())
 		return err
 	}
-	if user.IsSuperAdmin {
-		superUser = user
-	}
+
 	return nil
 }
 

+ 248 - 0
logic/dns.go

@@ -9,6 +9,7 @@ import (
 	"os"
 	"regexp"
 	"sort"
+	"strings"
 
 	validator "github.com/go-playground/validator/v10"
 	"github.com/gravitl/netmaker/database"
@@ -16,9 +17,49 @@ import (
 	"github.com/gravitl/netmaker/logger"
 	"github.com/gravitl/netmaker/models"
 	"github.com/gravitl/netmaker/schema"
+	"github.com/gravitl/netmaker/servercfg"
 	"github.com/txn2/txeh"
 )
 
+var GetNameserversForNode = getNameserversForNode
+var GetNameserversForHost = getNameserversForHost
+var ValidateNameserverReq = validateNameserverReq
+
+type GlobalNs struct {
+	ID  string   `json:"id"`
+	IPs []string `json:"ips"`
+}
+
+var GlobalNsList = map[string]GlobalNs{
+	"Google": {
+		ID: "Google",
+		IPs: []string{
+			"8.8.8.8",
+			"8.8.4.4",
+			"2001:4860:4860::8888",
+			"2001:4860:4860::8844",
+		},
+	},
+	"Cloudflare": {
+		ID: "Cloudflare",
+		IPs: []string{
+			"1.1.1.1",
+			"1.0.0.1",
+			"2606:4700:4700::1111",
+			"2606:4700:4700::1001",
+		},
+	},
+	"Quad9": {
+		ID: "Quad9",
+		IPs: []string{
+			"9.9.9.9",
+			"149.112.112.112",
+			"2620:fe::fe",
+			"2620:fe::9",
+		},
+	},
+}
+
 // SetDNS - sets the dns on file
 func SetDNS() error {
 	hostfile, err := txeh.NewHosts(&txeh.HostsConfig{})
@@ -162,6 +203,34 @@ func GetNodeDNS(network string) ([]models.DNSEntry, error) {
 	return dns, nil
 }
 
+func GetGwDNS(node *models.Node) string {
+	if !servercfg.GetManageDNS() {
+		return ""
+	}
+	h, err := GetHost(node.HostID.String())
+	if err != nil {
+		return ""
+	}
+	if h.DNS != "yes" {
+		return ""
+	}
+	dns := []string{}
+	if node.Address.IP != nil {
+		dns = append(dns, node.Address.IP.String())
+	}
+	if node.Address6.IP != nil {
+		dns = append(dns, node.Address6.IP.String())
+	}
+	return strings.Join(dns, ",")
+
+}
+
+func SetDNSOnWgConfig(gwNode *models.Node, extclient *models.ExtClient) {
+	if extclient.DNS == "" {
+		extclient.DNS = GetGwDNS(gwNode)
+	}
+}
+
 // GetCustomDNS - gets the custom DNS of a network
 func GetCustomDNS(network string) ([]models.DNSEntry, error) {
 
@@ -354,3 +423,182 @@ func CreateDNS(entry models.DNSEntry) (models.DNSEntry, error) {
 	err = database.Insert(k, string(data), database.DNS_TABLE_NAME)
 	return entry, err
 }
+
+func validateNameserverReq(ns schema.Nameserver) error {
+	if ns.Name == "" {
+		return errors.New("name is required")
+	}
+	if ns.NetworkID == "" {
+		return errors.New("network is required")
+	}
+	if len(ns.Servers) == 0 {
+		return errors.New("atleast one nameserver should be specified")
+	}
+	if !ns.MatchAll && len(ns.MatchDomains) == 0 {
+		return errors.New("atleast one match domain is required")
+	}
+	if !ns.MatchAll {
+		for _, matchDomain := range ns.MatchDomains {
+			if !IsValidMatchDomain(matchDomain) {
+				return errors.New("invalid match domain")
+			}
+		}
+	}
+
+	return nil
+}
+
+func getNameserversForNode(node *models.Node) (returnNsLi []models.Nameserver) {
+	ns := &schema.Nameserver{
+		NetworkID: node.Network,
+	}
+	nsLi, _ := ns.ListByNetwork(db.WithContext(context.TODO()))
+	for _, nsI := range nsLi {
+		if !nsI.Status {
+			continue
+		}
+		_, all := nsI.Tags["*"]
+		if all {
+			for _, matchDomain := range nsI.MatchDomains {
+				returnNsLi = append(returnNsLi, models.Nameserver{
+					IPs:         nsI.Servers,
+					MatchDomain: matchDomain,
+				})
+			}
+			continue
+		}
+
+		if _, ok := nsI.Nodes[node.ID.String()]; ok {
+			for _, matchDomain := range nsI.MatchDomains {
+				returnNsLi = append(returnNsLi, models.Nameserver{
+					IPs:         nsI.Servers,
+					MatchDomain: matchDomain,
+				})
+			}
+		}
+
+	}
+	if node.IsInternetGateway {
+		globalNs := models.Nameserver{
+			MatchDomain: ".",
+		}
+		for _, nsI := range GlobalNsList {
+			globalNs.IPs = append(globalNs.IPs, nsI.IPs...)
+		}
+		returnNsLi = append(returnNsLi, globalNs)
+	}
+	return
+}
+
+func getNameserversForHost(h *models.Host) (returnNsLi []models.Nameserver) {
+	if h.DNS != "yes" {
+		return
+	}
+	for _, nodeID := range h.Nodes {
+		node, err := GetNodeByID(nodeID)
+		if err != nil {
+			continue
+		}
+		ns := &schema.Nameserver{
+			NetworkID: node.Network,
+		}
+		nsLi, _ := ns.ListByNetwork(db.WithContext(context.TODO()))
+		for _, nsI := range nsLi {
+			if !nsI.Status {
+				continue
+			}
+			_, all := nsI.Tags["*"]
+			if all {
+				for _, matchDomain := range nsI.MatchDomains {
+					returnNsLi = append(returnNsLi, models.Nameserver{
+						IPs:         nsI.Servers,
+						MatchDomain: matchDomain,
+					})
+				}
+				continue
+			}
+
+			if _, ok := nsI.Nodes[node.ID.String()]; ok {
+				for _, matchDomain := range nsI.MatchDomains {
+					returnNsLi = append(returnNsLi, models.Nameserver{
+						IPs:         nsI.Servers,
+						MatchDomain: matchDomain,
+					})
+				}
+
+			}
+
+		}
+		if node.IsInternetGateway {
+			globalNs := models.Nameserver{
+				MatchDomain: ".",
+			}
+			for _, nsI := range GlobalNsList {
+				globalNs.IPs = append(globalNs.IPs, nsI.IPs...)
+			}
+			returnNsLi = append(returnNsLi, globalNs)
+		}
+	}
+	return
+}
+
+// IsValidMatchDomain reports whether s is a valid "match domain".
+// Rules (simple/ASCII):
+//   - "~." is allowed (match all).
+//   - Optional leading "~" allowed (e.g., "~example.com").
+//   - Optional single trailing "." allowed (FQDN form).
+//   - No wildcards "*", no leading ".", no underscores.
+//   - Labels: letters/digits/hyphen (LDH), 1–63 chars, no leading/trailing hyphen.
+//   - Total length (without trailing dot) ≤ 253.
+func IsValidMatchDomain(s string) bool {
+	s = strings.TrimSpace(s)
+	if s == "" {
+		return false
+	}
+	if s == "~." { // special case: match-all
+		return true
+	}
+
+	// Strip optional leading "~"
+	if strings.HasPrefix(s, "~") {
+		s = s[1:]
+		if s == "" {
+			return false
+		}
+	}
+
+	// Allow exactly one trailing dot
+	if strings.HasSuffix(s, ".") {
+		s = s[:len(s)-1]
+		if s == "" {
+			return false
+		}
+	}
+
+	// Disallow leading dot, wildcards, underscores
+	if strings.HasPrefix(s, ".") || strings.Contains(s, "*") || strings.Contains(s, "_") {
+		return false
+	}
+
+	// Lowercase for ASCII checks
+	s = strings.ToLower(s)
+
+	// Length check
+	if len(s) > 253 {
+		return false
+	}
+
+	// Label regex: LDH, 1–63, no leading/trailing hyphen
+	reLabel := regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$`)
+
+	parts := strings.Split(s, ".")
+	for _, lbl := range parts {
+		if len(lbl) == 0 || len(lbl) > 63 {
+			return false
+		}
+		if !reLabel.MatchString(lbl) {
+			return false
+		}
+	}
+	return true
+}

+ 1 - 1
logic/gateway.go

@@ -247,7 +247,7 @@ func GetIngressGwUsers(node models.Node) (models.IngressGwUsers, error) {
 		return gwUsers, err
 	}
 	for _, user := range users {
-		if !user.IsAdmin && !user.IsSuperAdmin {
+		if user.PlatformRoleID != models.SuperAdminRole && user.PlatformRoleID != models.AdminRole {
 			gwUsers.Users = append(gwUsers.Users, user)
 		}
 	}

+ 1 - 0
logic/peers.go

@@ -142,6 +142,7 @@ func GetPeerUpdateForHost(network string, host *models.Host, allNodes []models.N
 		NodePeers:       []wgtypes.PeerConfig{},
 		HostNetworkInfo: models.HostInfoMap{},
 		ServerConfig:    GetServerInfo(),
+		DnsNameservers:  GetNameserversForHost(host),
 	}
 	if host.DNS == "no" {
 		hostPeerUpdate.ManageDNS = false

+ 18 - 0
logic/settings.go

@@ -44,6 +44,24 @@ func UpsertServerSettings(s models.ServerSettings) error {
 		s.BasicAuth = true
 	}
 
+	var userFilters []string
+	for _, userFilter := range s.UserFilters {
+		userFilter = strings.TrimSpace(userFilter)
+		if userFilter != "" {
+			userFilters = append(userFilters, userFilter)
+		}
+	}
+	s.UserFilters = userFilters
+
+	var groupFilters []string
+	for _, groupFilter := range s.GroupFilters {
+		groupFilter = strings.TrimSpace(groupFilter)
+		if groupFilter != "" {
+			groupFilters = append(groupFilters, groupFilter)
+		}
+	}
+	s.GroupFilters = groupFilters
+
 	data, err := json.Marshal(s)
 	if err != nil {
 		return err

+ 1 - 1
logic/users.go

@@ -82,7 +82,7 @@ func GetSuperAdmin() (models.ReturnUser, error) {
 		return models.ReturnUser{}, err
 	}
 	for _, user := range users {
-		if user.IsSuperAdmin || user.PlatformRoleID == models.SuperAdminRole {
+		if user.PlatformRoleID == models.SuperAdminRole {
 			return user, nil
 		}
 	}

+ 94 - 3
migrate/migrate.go

@@ -39,6 +39,7 @@ func Run() {
 	logic.MigrateToGws()
 	migrateToEgressV1()
 	updateNetworks()
+	migrateNameservers()
 	resync()
 }
 
@@ -50,6 +51,78 @@ func updateNetworks() {
 			logic.UpsertNetwork(netI)
 		}
 	}
+
+}
+
+func migrateNameservers() {
+	nets, _ := logic.GetNetworks()
+	user, err := logic.GetSuperAdmin()
+	if err != nil {
+		return
+	}
+
+	for _, netI := range nets {
+		if len(netI.NameServers) > 0 {
+			ns := schema.Nameserver{
+				ID:           uuid.NewString(),
+				Name:         "upstream nameservers",
+				NetworkID:    netI.NetID,
+				Servers:      []string{},
+				MatchAll:     true,
+				MatchDomains: []string{"."},
+				Tags: datatypes.JSONMap{
+					"*": struct{}{},
+				},
+				Nodes:     make(datatypes.JSONMap),
+				Status:    true,
+				CreatedBy: user.UserName,
+			}
+			for _, ip := range netI.NameServers {
+				ns.Servers = append(ns.Servers, ip)
+			}
+			ns.Create(db.WithContext(context.TODO()))
+			netI.NameServers = []string{}
+			logic.SaveNetwork(&netI)
+		}
+	}
+	nodes, _ := logic.GetAllNodes()
+	for _, node := range nodes {
+		if !node.IsGw {
+			continue
+		}
+		if node.IngressDNS != "" {
+			if (node.Address.IP != nil && node.Address.IP.String() == node.IngressDNS) ||
+				(node.Address6.IP != nil && node.Address6.IP.String() == node.IngressDNS) {
+				continue
+			}
+			if node.IngressDNS == "8.8.8.8" || node.IngressDNS == "1.1.1.1" || node.IngressDNS == "9.9.9.9" {
+				continue
+			}
+			h, err := logic.GetHost(node.HostID.String())
+			if err != nil {
+				continue
+			}
+			ns := schema.Nameserver{
+				ID:           uuid.NewString(),
+				Name:         fmt.Sprintf("%s gw nameservers", h.Name),
+				NetworkID:    node.Network,
+				Servers:      []string{node.IngressDNS},
+				MatchAll:     true,
+				MatchDomains: []string{"."},
+				Nodes: datatypes.JSONMap{
+					node.ID.String(): struct{}{},
+				},
+				Tags:      make(datatypes.JSONMap),
+				Status:    true,
+				CreatedBy: user.UserName,
+			}
+			ns.Create(db.WithContext(context.TODO()))
+			node.IngressDNS = ""
+			logic.UpsertNode(&node)
+		}
+
+	}
+
 }
 
 // removes if any stale configurations from previous run.
@@ -125,7 +198,15 @@ func assignSuperAdmin() {
 		return
 	}
 	for _, u := range users {
-		if u.IsAdmin {
+		var isAdmin bool
+		if u.PlatformRoleID == models.AdminRole {
+			isAdmin = true
+		}
+		if u.PlatformRoleID == "" && u.IsAdmin {
+			isAdmin = true
+		}
+
+		if isAdmin {
 			user, err := logic.GetUser(u.UserName)
 			if err != nil {
 				slog.Error("error getting user", "user", u.UserName, "error", err.Error())
@@ -530,11 +611,18 @@ func syncUsers() {
 			user := user
 			if user.PlatformRoleID == models.AdminRole && !user.IsAdmin {
 				user.IsAdmin = true
+				user.IsSuperAdmin = false
 				logic.UpsertUser(user)
 			}
 			if user.PlatformRoleID == models.SuperAdminRole && !user.IsSuperAdmin {
 				user.IsSuperAdmin = true
-
+				user.IsAdmin = true
+				logic.UpsertUser(user)
+			}
+			if user.PlatformRoleID == models.PlatformUser || user.PlatformRoleID == models.ServiceUser {
+				user.IsSuperAdmin = false
+				user.IsAdmin = false
+				logic.UpsertUser(user)
 			}
 			if user.PlatformRoleID.String() != "" {
 				logic.MigrateUserRoleAndGroups(user)
@@ -552,9 +640,12 @@ func syncUsers() {
 			if len(user.UserGroups) == 0 {
 				user.UserGroups = make(map[models.UserGroupID]struct{})
 			}
+
+			// We reach here only if the platform role id has not been set.
+			//
+			// Thus, we use the boolean fields to assign the role.
 			if user.IsSuperAdmin {
 				user.PlatformRoleID = models.SuperAdminRole
-
 			} else if user.IsAdmin {
 				user.PlatformRoleID = models.AdminRole
 			} else {

+ 10 - 0
models/dnsEntry.go

@@ -47,3 +47,13 @@ type DNSEntry struct {
 	Name     string `json:"name" validate:"required,name_unique,min=1,max=192,whitespace"`
 	Network  string `json:"network" validate:"network_exists"`
 }
+
+type NameserverReq struct {
+	Name        string   `json:"name"`
+	Network     string   `json:"network"`
+	Description string   ` json:"description"`
+	Servers     []string `json:"servers"`
+	MatchDomain string   `json:"match_domain"`
+	Tags        []string `json:"tags"`
+	Status      bool     `gorm:"status" json:"status"`
+}

+ 1 - 0
models/events.go

@@ -53,6 +53,7 @@ const (
 	DashboardSub       SubjectType = "DASHBOARD"
 	EnrollmentKeySub   SubjectType = "ENROLLMENT_KEY"
 	ClientAppSub       SubjectType = "CLIENT-APP"
+	NameserverSub      SubjectType = "NAMESERVER"
 )
 
 func (sub SubjectType) String() string {

+ 5 - 0
models/mqtt.go

@@ -28,6 +28,7 @@ type HostPeerUpdate struct {
 	FwUpdate          FwUpdate              `json:"fw_update"`
 	ReplacePeers      bool                  `json:"replace_peers"`
 	NameServers       []string              `json:"name_servers"`
+	DnsNameservers    []Nameserver          `json:"dns_nameservers"`
 	EgressWithDomains []EgressDomain        `json:"egress_with_domains"`
 	ServerConfig
 	OldPeerUpdateFields
@@ -39,6 +40,10 @@ type EgressDomain struct {
 	Host   Host   `json:"host"`
 	Domain string `json:"domain"`
 }
+type Nameserver struct {
+	IPs         []string `json:"ips"`
+	MatchDomain string   `json:"match_domain"`
+}
 
 type OldPeerUpdateFields struct {
 	NodePeers         []wgtypes.PeerConfig `json:"peers" bson:"peers" yaml:"peers"`

+ 2 - 0
models/structs.go

@@ -53,6 +53,7 @@ type UserRemoteGws struct {
 	Status            NodeStatus `json:"status"`
 	DnsAddress        string     `json:"dns_address"`
 	Addresses         string     `json:"addresses"`
+	MatchDomains      []string   `json:"match_domains"`
 }
 
 // UserRAGs - struct for user access gws
@@ -263,6 +264,7 @@ type HostPull struct {
 	EndpointDetection bool                  `json:"endpoint_detection"`
 	NameServers       []string              `json:"name_servers"`
 	EgressWithDomains []EgressDomain        `json:"egress_with_domains"`
+	DnsNameservers    []Nameserver          `json:"dns_nameservers"`
 }
 
 type DefaultGwInfo struct {

+ 8 - 7
models/user_mgmt.go

@@ -62,21 +62,21 @@ var RsrcTypeMap = map[RsrcType]struct{}{
 
 const AllNetworks NetworkID = "all_networks"
 const (
-	HostRsrc           RsrcType = "hosts"
-	RelayRsrc          RsrcType = "relays"
+	HostRsrc           RsrcType = "host"
+	RelayRsrc          RsrcType = "relay"
 	RemoteAccessGwRsrc RsrcType = "remote_access_gw"
-	GatewayRsrc        RsrcType = "gateways"
-	ExtClientsRsrc     RsrcType = "extclients"
+	GatewayRsrc        RsrcType = "gateway"
+	ExtClientsRsrc     RsrcType = "extclient"
 	InetGwRsrc         RsrcType = "inet_gw"
 	EgressGwRsrc       RsrcType = "egress"
-	NetworkRsrc        RsrcType = "networks"
+	NetworkRsrc        RsrcType = "network"
 	EnrollmentKeysRsrc RsrcType = "enrollment_key"
-	UserRsrc           RsrcType = "users"
+	UserRsrc           RsrcType = "user"
 	AclRsrc            RsrcType = "acl"
 	TagRsrc            RsrcType = "tag"
 	DnsRsrc            RsrcType = "dns"
 	FailOverRsrc       RsrcType = "fail_over"
-	MetricRsrc         RsrcType = "metrics"
+	MetricRsrc         RsrcType = "metric"
 )
 
 const (
@@ -150,6 +150,7 @@ type UserGroup struct {
 	Default                    bool                                  `json:"default"`
 	Name                       string                                `json:"name"`
 	NetworkRoles               map[NetworkID]map[UserRoleID]struct{} `json:"network_roles"`
+	ColorCode                  string                                `json:"color_code"`
 	MetaData                   string                                `json:"meta_data"`
 }
 

+ 1 - 0
mq/publishers.go

@@ -113,6 +113,7 @@ func PublishSingleHostPeerUpdate(host *models.Host, allNodes []models.Node, dele
 	if err != nil {
 		return err
 	}
+
 	for _, nodeID := range host.Nodes {
 
 		node, err := logic.GetNodeByID(nodeID)

+ 70 - 2
pro/auth/sync.go

@@ -95,15 +95,23 @@ func SyncFromIDP() error {
 	}
 
 	if settings.AuthProvider != "" && idpClient != nil {
-		idpUsers, err = idpClient.GetUsers()
+		idpUsers, err = idpClient.GetUsers(settings.UserFilters)
 		if err != nil {
 			return err
 		}
 
-		idpGroups, err = idpClient.GetGroups()
+		idpGroups, err = idpClient.GetGroups(settings.GroupFilters)
 		if err != nil {
 			return err
 		}
+
+		if len(settings.GroupFilters) > 0 {
+			idpUsers = filterUsersByGroupMembership(idpUsers, idpGroups)
+		}
+
+		if len(settings.UserFilters) > 0 {
+			idpGroups = filterGroupsByMembers(idpGroups, idpUsers)
+		}
 	}
 
 	err = syncUsers(idpUsers)
@@ -347,3 +355,63 @@ func GetIDPSyncStatus() models.IDPSyncStatus {
 		}
 	}
 }
+func filterUsersByGroupMembership(idpUsers []idp.User, idpGroups []idp.Group) []idp.User {
+	usersMap := make(map[string]int)
+	for i, user := range idpUsers {
+		usersMap[user.ID] = i
+	}
+
+	filteredUsersMap := make(map[string]int)
+	for _, group := range idpGroups {
+		for _, member := range group.Members {
+			if userIdx, ok := usersMap[member]; ok {
+				// user at index `userIdx` is a member of at least one of the
+				// groups in the `idpGroups` list, so we keep it.
+				filteredUsersMap[member] = userIdx
+			}
+		}
+	}
+
+	i := 0
+	filteredUsers := make([]idp.User, len(filteredUsersMap))
+	for _, userIdx := range filteredUsersMap {
+		filteredUsers[i] = idpUsers[userIdx]
+		i++
+	}
+
+	return filteredUsers
+}
+
+func filterGroupsByMembers(idpGroups []idp.Group, idpUsers []idp.User) []idp.Group {
+	usersMap := make(map[string]int)
+	for i, user := range idpUsers {
+		usersMap[user.ID] = i
+	}
+
+	filteredGroupsMap := make(map[int]bool)
+	for i, group := range idpGroups {
+		var members []string
+		for _, member := range group.Members {
+			if _, ok := usersMap[member]; ok {
+				members = append(members, member)
+			}
+
+			if len(members) > 0 {
+				// the group at index `i` has members from the `idpUsers` list,
+				// so we keep it.
+				filteredGroupsMap[i] = true
+				// filter out members that were not provided in the `idpUsers` list.
+				idpGroups[i].Members = members
+			}
+		}
+	}
+
+	i := 0
+	filteredGroups := make([]idp.Group, len(filteredGroupsMap))
+	for groupIdx := range filteredGroupsMap {
+		filteredGroups[i] = idpGroups[groupIdx]
+		i++
+	}
+
+	return filteredGroups
+}

+ 147 - 59
pro/controllers/users.go

@@ -5,15 +5,16 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/gravitl/netmaker/pro/idp"
-	"github.com/gravitl/netmaker/pro/idp/azure"
-	"github.com/gravitl/netmaker/pro/idp/google"
-	"github.com/gravitl/netmaker/pro/idp/okta"
 	"net/http"
 	"net/url"
 	"strings"
 	"time"
 
+	"github.com/gravitl/netmaker/pro/idp"
+	"github.com/gravitl/netmaker/pro/idp/azure"
+	"github.com/gravitl/netmaker/pro/idp/google"
+	"github.com/gravitl/netmaker/pro/idp/okta"
+
 	"github.com/google/uuid"
 	"github.com/gorilla/mux"
 	"github.com/gravitl/netmaker/database"
@@ -48,6 +49,8 @@ func UserHandlers(r *mux.Router) {
 	r.HandleFunc("/api/v1/users/group", logic.SecurityCheck(true, http.HandlerFunc(createUserGroup))).Methods(http.MethodPost)
 	r.HandleFunc("/api/v1/users/group", logic.SecurityCheck(true, http.HandlerFunc(updateUserGroup))).Methods(http.MethodPut)
 	r.HandleFunc("/api/v1/users/group", logic.SecurityCheck(true, http.HandlerFunc(deleteUserGroup))).Methods(http.MethodDelete)
+	r.HandleFunc("/api/v1/users/add_network_user", logic.SecurityCheck(true, http.HandlerFunc(addUsertoNetwork))).Methods(http.MethodPut)
+	r.HandleFunc("/api/v1/users/remove_network_user", logic.SecurityCheck(true, http.HandlerFunc(removeUserfromNetwork))).Methods(http.MethodPut)
 
 	// User Invite Handlers
 	r.HandleFunc("/api/v1/users/invite", userInviteVerify).Methods(http.MethodGet)
@@ -471,45 +474,6 @@ func createUserGroup(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	for networkID := range userGroupReq.Group.NetworkRoles {
-		network, err := logic.GetNetwork(networkID.String())
-		if err != nil {
-			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
-			return
-		}
-
-		acl := models.Acl{
-			ID:          uuid.New().String(),
-			Name:        fmt.Sprintf("%s group", userGroupReq.Group.Name),
-			MetaData:    "This Policy allows user group to communicate with all gateways",
-			Default:     false,
-			ServiceType: models.Any,
-			NetworkID:   models.NetworkID(network.NetID),
-			Proto:       models.ALL,
-			RuleType:    models.UserPolicy,
-			Src: []models.AclPolicyTag{
-				{
-					ID:    models.UserGroupAclID,
-					Value: userGroupReq.Group.ID.String(),
-				},
-			},
-			Dst: []models.AclPolicyTag{
-				{
-					ID:    models.NodeTagID,
-					Value: fmt.Sprintf("%s.%s", models.NetworkID(network.NetID), models.GwTagName),
-				}},
-			AllowedDirection: models.TrafficDirectionUni,
-			Enabled:          true,
-			CreatedBy:        "auto",
-			CreatedAt:        time.Now().UTC(),
-		}
-		err = logic.InsertAcl(acl)
-		if err != nil {
-			logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
-			return
-		}
-	}
-
 	for _, userID := range userGroupReq.Members {
 		user, err := logic.GetUser(userID)
 		if err != nil {
@@ -536,6 +500,7 @@ func createUserGroup(w http.ResponseWriter, r *http.Request) {
 		},
 		Origin: models.Dashboard,
 	})
+	go mq.PublishPeerUpdate(false)
 	logic.ReturnSuccessResponseWithJson(w, r, userGroupReq.Group, "created user group")
 }
 
@@ -689,10 +654,124 @@ func updateUserGroup(w http.ResponseWriter, r *http.Request) {
 	}()
 
 	// reset configs for service user
-	go proLogic.UpdatesUserGwAccessOnGrpUpdates(currUserG.NetworkRoles, userGroup.NetworkRoles)
+	go proLogic.UpdatesUserGwAccessOnGrpUpdates(userGroup.ID, currUserG.NetworkRoles, userGroup.NetworkRoles)
 	logic.ReturnSuccessResponseWithJson(w, r, userGroup, "updated user group")
 }
 
+// swagger:route PUT /api/v1/users/add_network_user user addUsertoNetwork
+//
+// add user to network.
+//
+//			Schemes: https
+//
+//			Security:
+//	  		oauth
+//
+//			Responses:
+//				200: userBodyResponse
+func addUsertoNetwork(w http.ResponseWriter, r *http.Request) {
+	username := r.URL.Query().Get("username")
+	if username == "" {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("username is required"), logic.BadReq))
+		return
+	}
+	netID := r.URL.Query().Get("network_id")
+	if netID == "" {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("network is required"), logic.BadReq))
+		return
+	}
+	user, err := logic.GetUser(username)
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.BadReq))
+		return
+	}
+	if user.PlatformRoleID != models.ServiceUser {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("can only add service users"), logic.BadReq))
+		return
+	}
+	oldUser := *user
+	user.UserGroups[proLogic.GetDefaultNetworkUserGroupID(models.NetworkID(netID))] = struct{}{}
+	logic.UpsertUser(*user)
+	logic.LogEvent(&models.Event{
+		Action: models.Update,
+		Source: models.Subject{
+			ID:   r.Header.Get("user"),
+			Name: r.Header.Get("user"),
+			Type: models.UserSub,
+		},
+		TriggeredBy: r.Header.Get("user"),
+		Target: models.Subject{
+			ID:   user.UserName,
+			Name: user.UserName,
+			Type: models.UserSub,
+		},
+		Diff: models.Diff{
+			Old: oldUser,
+			New: user,
+		},
+		Origin: models.Dashboard,
+	})
+
+	logic.ReturnSuccessResponseWithJson(w, r, user, "updated user group")
+}
+
+// swagger:route PUT /api/v1/users/remove_network_user user removeUserfromNetwork
+//
+// add user to network.
+//
+//			Schemes: https
+//
+//			Security:
+//	  		oauth
+//
+//			Responses:
+//				200: userBodyResponse
+func removeUserfromNetwork(w http.ResponseWriter, r *http.Request) {
+	username := r.URL.Query().Get("username")
+	if username == "" {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("username is required"), logic.BadReq))
+		return
+	}
+	netID := r.URL.Query().Get("network_id")
+	if netID == "" {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("network is required"), logic.BadReq))
+		return
+	}
+	user, err := logic.GetUser(username)
+	if err != nil {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, logic.BadReq))
+		return
+	}
+	if user.PlatformRoleID != models.ServiceUser {
+		logic.ReturnErrorResponse(w, r, logic.FormatError(errors.New("can only add service users"), logic.BadReq))
+		return
+	}
+	oldUser := *user
+	delete(user.UserGroups, proLogic.GetDefaultNetworkUserGroupID(models.NetworkID(netID)))
+	logic.UpsertUser(*user)
+	logic.LogEvent(&models.Event{
+		Action: models.Update,
+		Source: models.Subject{
+			ID:   r.Header.Get("user"),
+			Name: r.Header.Get("user"),
+			Type: models.UserSub,
+		},
+		TriggeredBy: r.Header.Get("user"),
+		Target: models.Subject{
+			ID:   user.UserName,
+			Name: user.UserName,
+			Type: models.UserSub,
+		},
+		Diff: models.Diff{
+			Old: oldUser,
+			New: user,
+		},
+		Origin: models.Dashboard,
+	})
+
+	logic.ReturnSuccessResponseWithJson(w, r, user, "updated user group")
+}
+
 // swagger:route DELETE /api/v1/user/group user deleteUserGroup
 //
 // delete user group.
@@ -780,7 +859,8 @@ func deleteUserGroup(w http.ResponseWriter, r *http.Request) {
 		}
 	}()
 
-	go proLogic.UpdatesUserGwAccessOnGrpUpdates(userG.NetworkRoles, make(map[models.NetworkID]map[models.UserRoleID]struct{}))
+	go proLogic.UpdatesUserGwAccessOnGrpUpdates(userG.ID, userG.NetworkRoles, make(map[models.NetworkID]map[models.UserRoleID]struct{}))
+	go mq.PublishPeerUpdate(false)
 	logic.ReturnSuccessResponseWithJson(w, r, nil, "deleted user group")
 }
 
@@ -1299,11 +1379,7 @@ func getRemoteAccessGatewayConf(w http.ResponseWriter, r *http.Request) {
 		userConf.OwnerID = user.UserName
 		userConf.RemoteAccessClientID = req.RemoteAccessClientID
 		userConf.IngressGatewayID = node.ID.String()
-
-		// set extclient dns to ingressdns if extclient dns is not explicitly set
-		if (userConf.DNS == "") && (node.IngressDNS != "") {
-			userConf.DNS = node.IngressDNS
-		}
+		logic.SetDNSOnWgConfig(&node, &userConf)
 
 		userConf.Network = node.Network
 		host, err := logic.GetHost(node.HostID.String())
@@ -1477,7 +1553,7 @@ func getUserRemoteAccessGwsV1(w http.ResponseWriter, r *http.Request) {
 			logic.GetPeerListenPort(host),
 		)
 		gwClient.AllowedIPs = logic.GetExtclientAllowedIPs(gwClient)
-		gws = append(gws, models.UserRemoteGws{
+		gw := models.UserRemoteGws{
 			GwID:              node.ID.String(),
 			GWName:            host.Name,
 			Network:           node.Network,
@@ -1492,7 +1568,14 @@ func getUserRemoteAccessGwsV1(w http.ResponseWriter, r *http.Request) {
 			Status:            node.Status,
 			DnsAddress:        node.IngressDNS,
 			Addresses:         utils.NoEmptyStringToCsv(node.Address.String(), node.Address6.String()),
-		})
+		}
+		if !node.IsInternetGateway {
+			hNs := logic.GetNameserversForNode(&node)
+			for _, nsI := range hNs {
+				gw.MatchDomains = append(gw.MatchDomains, nsI.MatchDomain)
+			}
+		}
+		gws = append(gws, gw)
 		userGws[node.Network] = gws
 		delete(userGwNodes, node.ID.String())
 	}
@@ -1522,8 +1605,7 @@ func getUserRemoteAccessGwsV1(w http.ResponseWriter, r *http.Request) {
 			slog.Error("failed to get node network", "error", err)
 		}
 		gws := userGws[node.Network]
-
-		gws = append(gws, models.UserRemoteGws{
+		gw := models.UserRemoteGws{
 			GwID:              node.ID.String(),
 			GWName:            host.Name,
 			Network:           node.Network,
@@ -1536,7 +1618,14 @@ func getUserRemoteAccessGwsV1(w http.ResponseWriter, r *http.Request) {
 			Status:            node.Status,
 			DnsAddress:        node.IngressDNS,
 			Addresses:         utils.NoEmptyStringToCsv(node.Address.String(), node.Address6.String()),
-		})
+		}
+		if !node.IsInternetGateway {
+			hNs := logic.GetNameserversForNode(&node)
+			for _, nsI := range hNs {
+				gw.MatchDomains = append(gw.MatchDomains, nsI.MatchDomain)
+			}
+		}
+		gws = append(gws, gw)
 		userGws[node.Network] = gws
 	}
 
@@ -1855,11 +1944,10 @@ func removeIDPIntegration(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if superAdmin.AuthType == models.OAuth {
-		logic.ReturnErrorResponse(
-			w,
-			r,
-			logic.FormatError(fmt.Errorf("cannot remove idp integration with superadmin oauth user"), "badrequest"),
+		err := fmt.Errorf(
+			"cannot remove IdP integration because an OAuth user has the super-admin role; transfer the super-admin role to another user first",
 		)
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
 		return
 	}
 

+ 82 - 61
pro/idp/azure/azure.go

@@ -4,10 +4,11 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/gravitl/netmaker/logic"
-	"github.com/gravitl/netmaker/pro/idp"
 	"net/http"
 	"net/url"
+
+	"github.com/gravitl/netmaker/logic"
+	"github.com/gravitl/netmaker/pro/idp"
 )
 
 type Client struct {
@@ -92,97 +93,103 @@ func (a *Client) Verify() error {
 	return nil
 }
 
-func (a *Client) GetUsers() ([]idp.User, error) {
+func (a *Client) GetUsers(filters []string) ([]idp.User, error) {
 	accessToken, err := a.getAccessToken()
 	if err != nil {
 		return nil, err
 	}
 
 	client := &http.Client{}
-	req, err := http.NewRequest("GET", "https://graph.microsoft.com/v1.0/users?$select=id,userPrincipalName,displayName,accountEnabled", nil)
-	if err != nil {
-		return nil, err
+	getUsersURL := "https://graph.microsoft.com/v1.0/users?$select=id,userPrincipalName,displayName,accountEnabled"
+	if len(filters) > 0 {
+		getUsersURL += "&" + buildPrefixFilter("userPrincipalName", filters)
 	}
 
-	req.Header.Add("Authorization", "Bearer "+accessToken)
-	req.Header.Add("Accept", "application/json")
+	var retval []idp.User
+	for getUsersURL != "" {
+		req, err := http.NewRequest("GET", getUsersURL, nil)
+		if err != nil {
+			return nil, err
+		}
 
-	resp, err := client.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer func() {
-		_ = resp.Body.Close()
-	}()
+		req.Header.Add("Authorization", "Bearer "+accessToken)
+		req.Header.Add("Accept", "application/json")
 
-	var users getUsersResponse
-	err = json.NewDecoder(resp.Body).Decode(&users)
-	if err != nil {
-		return nil, err
-	}
+		resp, err := client.Do(req)
+		if err != nil {
+			return nil, err
+		}
 
-	if users.Error.Code != "" {
-		return nil, errors.New(users.Error.Message)
-	}
+		var users getUsersResponse
+		err = json.NewDecoder(resp.Body).Decode(&users)
+		_ = resp.Body.Close()
+		if err != nil {
+			return nil, err
+		}
 
-	retval := make([]idp.User, len(users.Value))
-	for i, user := range users.Value {
-		retval[i] = idp.User{
-			ID:              user.Id,
-			Username:        user.UserPrincipalName,
-			DisplayName:     user.DisplayName,
-			AccountDisabled: !user.AccountEnabled,
+		for _, user := range users.Value {
+			retval = append(retval, idp.User{
+				ID:              user.Id,
+				Username:        user.UserPrincipalName,
+				DisplayName:     user.DisplayName,
+				AccountDisabled: !user.AccountEnabled,
+			})
 		}
+
+		getUsersURL = users.NextLink
 	}
 
 	return retval, nil
 }
 
-func (a *Client) GetGroups() ([]idp.Group, error) {
+func (a *Client) GetGroups(filters []string) ([]idp.Group, error) {
 	accessToken, err := a.getAccessToken()
 	if err != nil {
 		return nil, err
 	}
 
 	client := &http.Client{}
-	req, err := http.NewRequest("GET", "https://graph.microsoft.com/v1.0/groups?$select=id,displayName&$expand=members($select=id)", nil)
-	if err != nil {
-		return nil, err
+	getGroupsURL := "https://graph.microsoft.com/v1.0/groups?$select=id,displayName&$expand=members($select=id)"
+	if len(filters) > 0 {
+		getGroupsURL += "&" + buildPrefixFilter("displayName", filters)
 	}
 
-	req.Header.Add("Authorization", "Bearer "+accessToken)
-	req.Header.Add("Accept", "application/json")
-
-	resp, err := client.Do(req)
-	if err != nil {
-		return nil, err
-	}
-	defer func() {
-		_ = resp.Body.Close()
-	}()
+	var retval []idp.Group
+	for getGroupsURL != "" {
+		req, err := http.NewRequest("GET", getGroupsURL, nil)
+		if err != nil {
+			return nil, err
+		}
 
-	var groups getGroupsResponse
-	err = json.NewDecoder(resp.Body).Decode(&groups)
-	if err != nil {
-		return nil, err
-	}
+		req.Header.Add("Authorization", "Bearer "+accessToken)
+		req.Header.Add("Accept", "application/json")
 
-	if groups.Error.Code != "" {
-		return nil, errors.New(groups.Error.Message)
-	}
+		resp, err := client.Do(req)
+		if err != nil {
+			return nil, err
+		}
 
-	retval := make([]idp.Group, len(groups.Value))
-	for i, group := range groups.Value {
-		retvalMembers := make([]string, len(group.Members))
-		for j, member := range group.Members {
-			retvalMembers[j] = member.Id
+		var groups getGroupsResponse
+		err = json.NewDecoder(resp.Body).Decode(&groups)
+		_ = resp.Body.Close()
+		if err != nil {
+			return nil, err
 		}
 
-		retval[i] = idp.Group{
-			ID:      group.Id,
-			Name:    group.DisplayName,
-			Members: retvalMembers,
+		for _, group := range groups.Value {
+			retvalMembers := make([]string, len(group.Members))
+			for j, member := range group.Members {
+				retvalMembers[j] = member.Id
+			}
+
+			retval = append(retval, idp.Group{
+				ID:      group.Id,
+				Name:    group.DisplayName,
+				Members: retvalMembers,
+			})
 		}
+
+		getGroupsURL = groups.NextLink
 	}
 
 	return retval, nil
@@ -218,6 +225,18 @@ func (a *Client) getAccessToken() (string, error) {
 	return "", errors.New("invalid credentials")
 }
 
+func buildPrefixFilter(field string, prefixes []string) string {
+	if len(prefixes) == 0 {
+		return ""
+	}
+
+	if len(prefixes) == 1 {
+		return fmt.Sprintf("$filter=startswith(%s,'%s')", field, prefixes[0])
+	}
+
+	return buildPrefixFilter(field, prefixes[:1]) + "%20or%20" + buildPrefixFilter(field, prefixes[1:])
+}
+
 type getUsersResponse struct {
 	Error        errorResponse `json:"error"`
 	OdataContext string        `json:"@odata.context"`
@@ -227,6 +246,7 @@ type getUsersResponse struct {
 		DisplayName       string `json:"displayName"`
 		AccountEnabled    bool   `json:"accountEnabled"`
 	} `json:"value"`
+	NextLink string `json:"@odata.nextLink"`
 }
 
 type getGroupsResponse struct {
@@ -240,6 +260,7 @@ type getGroupsResponse struct {
 			Id        string `json:"id"`
 		} `json:"members"`
 	} `json:"value"`
+	NextLink string `json:"@odata.nextLink"`
 }
 
 type errorResponse struct {

+ 36 - 4
pro/idp/google/google.go

@@ -5,14 +5,16 @@ import (
 	"encoding/base64"
 	"encoding/json"
 	"errors"
+	"strings"
+
+	"net/url"
+
 	"github.com/gravitl/netmaker/logic"
 	"github.com/gravitl/netmaker/pro/idp"
 	admindir "google.golang.org/api/admin/directory/v1"
 	"google.golang.org/api/googleapi"
 	"google.golang.org/api/impersonate"
 	"google.golang.org/api/option"
-	"net/url"
-	"strings"
 )
 
 type Client struct {
@@ -121,13 +123,28 @@ func (g *Client) Verify() error {
 	return nil
 }
 
-func (g *Client) GetUsers() ([]idp.User, error) {
+func (g *Client) GetUsers(filters []string) ([]idp.User, error) {
 	var retval []idp.User
 	err := g.service.Users.List().
 		Customer("my_customer").
 		Fields("users(id,primaryEmail,name,suspended,archived)", "nextPageToken").
 		Pages(context.TODO(), func(users *admindir.Users) error {
 			for _, user := range users.Users {
+				var keep bool
+				if len(filters) > 0 {
+					for _, filter := range filters {
+						if strings.HasPrefix(user.PrimaryEmail, filter) {
+							keep = true
+						}
+					}
+				} else {
+					keep = true
+				}
+
+				if !keep {
+					continue
+				}
+
 				retval = append(retval, idp.User{
 					ID:              user.Id,
 					Username:        user.PrimaryEmail,
@@ -143,13 +160,28 @@ func (g *Client) GetUsers() ([]idp.User, error) {
 	return retval, err
 }
 
-func (g *Client) GetGroups() ([]idp.Group, error) {
+func (g *Client) GetGroups(filters []string) ([]idp.Group, error) {
 	var retval []idp.Group
 	err := g.service.Groups.List().
 		Customer("my_customer").
 		Fields("groups(id,name)", "nextPageToken").
 		Pages(context.TODO(), func(groups *admindir.Groups) error {
 			for _, group := range groups.Groups {
+				var keep bool
+				if len(filters) > 0 {
+					for _, filter := range filters {
+						if strings.HasPrefix(group.Name, filter) {
+							keep = true
+						}
+					}
+				} else {
+					keep = true
+				}
+
+				if !keep {
+					continue
+				}
+
 				var retvalMembers []string
 				err := g.service.Members.List(group.Id).
 					Fields("members(id)", "nextPageToken").

+ 2 - 2
pro/idp/idp.go

@@ -2,8 +2,8 @@ package idp
 
 type Client interface {
 	Verify() error
-	GetUsers() ([]User, error)
-	GetGroups() ([]Group, error)
+	GetUsers(filters []string) ([]User, error)
+	GetGroups(filters []string) ([]Group, error)
 }
 
 type User struct {

+ 21 - 4
pro/idp/okta/okta.go

@@ -3,6 +3,7 @@ package okta
 import (
 	"context"
 	"fmt"
+
 	"github.com/gravitl/netmaker/logic"
 	"github.com/gravitl/netmaker/pro/idp"
 	"github.com/okta/okta-sdk-golang/v5/okta"
@@ -42,12 +43,14 @@ func (o *Client) Verify() error {
 	return err
 }
 
-func (o *Client) GetUsers() ([]idp.User, error) {
+func (o *Client) GetUsers(filters []string) ([]idp.User, error) {
 	var retval []idp.User
 	var allUsersFetched bool
 
 	for !allUsersFetched {
-		users, resp, err := o.client.UserAPI.ListUsers(context.TODO()).Execute()
+		users, resp, err := o.client.UserAPI.ListUsers(context.TODO()).
+			Search(buildPrefixFilter("profile.login", filters)).
+			Execute()
 		if err != nil {
 			return nil, err
 		}
@@ -81,12 +84,14 @@ func (o *Client) GetUsers() ([]idp.User, error) {
 	return retval, nil
 }
 
-func (o *Client) GetGroups() ([]idp.Group, error) {
+func (o *Client) GetGroups(filters []string) ([]idp.Group, error) {
 	var retval []idp.Group
 	var allGroupsFetched bool
 
 	for !allGroupsFetched {
-		groups, resp, err := o.client.GroupAPI.ListGroups(context.TODO()).Execute()
+		groups, resp, err := o.client.GroupAPI.ListGroups(context.TODO()).
+			Search(buildPrefixFilter("profile.name", filters)).
+			Execute()
 		if err != nil {
 			return nil, err
 		}
@@ -122,3 +127,15 @@ func (o *Client) GetGroups() ([]idp.Group, error) {
 
 	return retval, nil
 }
+
+func buildPrefixFilter(field string, prefixes []string) string {
+	if len(prefixes) == 0 {
+		return ""
+	}
+
+	if len(prefixes) == 1 {
+		return fmt.Sprintf("%s sw \"%s\"", field, prefixes[0])
+	}
+
+	return buildPrefixFilter(field, prefixes[:1]) + " or " + buildPrefixFilter(field, prefixes[1:])
+}

+ 4 - 0
pro/initialize.go

@@ -159,6 +159,10 @@ func InitPro() {
 	logic.GetFwRulesForUserNodesOnGw = proLogic.GetFwRulesForUserNodesOnGw
 	logic.GetHostLocInfo = proLogic.GetHostLocInfo
 	logic.GetFeatureFlags = proLogic.GetFeatureFlags
+	logic.GetNameserversForHost = proLogic.GetNameserversForHost
+	logic.GetNameserversForNode = proLogic.GetNameserversForNode
+	logic.ValidateNameserverReq = proLogic.ValidateNameserverReq
+
 }
 
 func retrieveProLogo() string {

+ 9 - 0
pro/logic/acls.go

@@ -516,6 +516,15 @@ func ListUserPolicies(u models.User) []models.Acl {
 func listPoliciesOfUser(user models.User, netID models.NetworkID) []models.Acl {
 	allAcls := logic.ListAcls()
 	userAcls := []models.Acl{}
+	if _, ok := user.UserGroups[globalNetworksAdminGroupID]; ok {
+		user.UserGroups[GetDefaultNetworkAdminGroupID(netID)] = struct{}{}
+	}
+	if _, ok := user.UserGroups[globalNetworksUserGroupID]; ok {
+		user.UserGroups[GetDefaultNetworkUserGroupID(netID)] = struct{}{}
+	}
+	if user.PlatformRoleID == models.AdminRole || user.PlatformRoleID == models.SuperAdminRole {
+		user.UserGroups[GetDefaultNetworkAdminGroupID(netID)] = struct{}{}
+	}
 	for _, acl := range allAcls {
 		if acl.NetworkID == netID && acl.RuleType == models.UserPolicy {
 			srcMap := logic.ConvAclTagToValueMap(acl.Src)

+ 171 - 0
pro/logic/dns.go

@@ -0,0 +1,171 @@
+package logic
+
+import (
+	"context"
+	"errors"
+
+	"github.com/gravitl/netmaker/db"
+	"github.com/gravitl/netmaker/logic"
+	"github.com/gravitl/netmaker/models"
+	"github.com/gravitl/netmaker/schema"
+)
+
+func ValidateNameserverReq(ns schema.Nameserver) error {
+	if ns.Name == "" {
+		return errors.New("name is required")
+	}
+	if ns.NetworkID == "" {
+		return errors.New("network is required")
+	}
+	if len(ns.Servers) == 0 {
+		return errors.New("atleast one nameserver should be specified")
+	}
+	if !ns.MatchAll && len(ns.MatchDomains) == 0 {
+		return errors.New("atleast one match domain is required")
+	}
+	if !ns.MatchAll {
+		for _, matchDomain := range ns.MatchDomains {
+			if !logic.IsValidMatchDomain(matchDomain) {
+				return errors.New("invalid match domain")
+			}
+		}
+	}
+	if len(ns.Tags) > 0 {
+		for tagI := range ns.Tags {
+			if tagI == "*" {
+				continue
+			}
+			_, err := GetTag(models.TagID(tagI))
+			if err != nil {
+				return errors.New("invalid tag")
+			}
+		}
+	}
+	return nil
+}
+
+func GetNameserversForNode(node *models.Node) (returnNsLi []models.Nameserver) {
+	ns := &schema.Nameserver{
+		NetworkID: node.Network,
+	}
+	nsLi, _ := ns.ListByNetwork(db.WithContext(context.TODO()))
+	for _, nsI := range nsLi {
+		if !nsI.Status {
+			continue
+		}
+		_, all := nsI.Tags["*"]
+		if all {
+			for _, matchDomain := range nsI.MatchDomains {
+				returnNsLi = append(returnNsLi, models.Nameserver{
+					IPs:         nsI.Servers,
+					MatchDomain: matchDomain,
+				})
+			}
+			continue
+		}
+		foundTag := false
+		for tagI := range node.Tags {
+			if _, ok := nsI.Tags[tagI.String()]; ok {
+				for _, matchDomain := range nsI.MatchDomains {
+					returnNsLi = append(returnNsLi, models.Nameserver{
+						IPs:         nsI.Servers,
+						MatchDomain: matchDomain,
+					})
+				}
+				foundTag = true
+			}
+			if foundTag {
+				break
+			}
+		}
+		if foundTag {
+			continue
+		}
+		if _, ok := nsI.Nodes[node.ID.String()]; ok {
+			for _, matchDomain := range nsI.MatchDomains {
+				returnNsLi = append(returnNsLi, models.Nameserver{
+					IPs:         nsI.Servers,
+					MatchDomain: matchDomain,
+				})
+			}
+		}
+	}
+	if node.IsInternetGateway {
+		globalNs := models.Nameserver{
+			MatchDomain: ".",
+		}
+		for _, nsI := range logic.GlobalNsList {
+			globalNs.IPs = append(globalNs.IPs, nsI.IPs...)
+		}
+		returnNsLi = append(returnNsLi, globalNs)
+	}
+	return
+}
+
+func GetNameserversForHost(h *models.Host) (returnNsLi []models.Nameserver) {
+	if h.DNS != "yes" {
+		return
+	}
+	for _, nodeID := range h.Nodes {
+		node, err := logic.GetNodeByID(nodeID)
+		if err != nil {
+			continue
+		}
+		ns := &schema.Nameserver{
+			NetworkID: node.Network,
+		}
+		nsLi, _ := ns.ListByNetwork(db.WithContext(context.TODO()))
+		for _, nsI := range nsLi {
+			if !nsI.Status {
+				continue
+			}
+			_, all := nsI.Tags["*"]
+			if all {
+				for _, matchDomain := range nsI.MatchDomains {
+					returnNsLi = append(returnNsLi, models.Nameserver{
+						IPs:         nsI.Servers,
+						MatchDomain: matchDomain,
+					})
+				}
+				continue
+			}
+			foundTag := false
+			for tagI := range node.Tags {
+				if _, ok := nsI.Tags[tagI.String()]; ok {
+					for _, matchDomain := range nsI.MatchDomains {
+						returnNsLi = append(returnNsLi, models.Nameserver{
+							IPs:         nsI.Servers,
+							MatchDomain: matchDomain,
+						})
+					}
+					foundTag = true
+				}
+				if foundTag {
+					break
+				}
+			}
+			if foundTag {
+				continue
+			}
+			if _, ok := nsI.Nodes[node.ID.String()]; ok {
+				for _, matchDomain := range nsI.MatchDomains {
+					returnNsLi = append(returnNsLi, models.Nameserver{
+						IPs:         nsI.Servers,
+						MatchDomain: matchDomain,
+					})
+				}
+			}
+
+		}
+		if node.IsInternetGateway {
+			globalNs := models.Nameserver{
+				MatchDomain: ".",
+			}
+			for _, nsI := range logic.GlobalNsList {
+				globalNs.IPs = append(globalNs.IPs, nsI.IPs...)
+			}
+			returnNsLi = append(returnNsLi, globalNs)
+		}
+	}
+	return
+}

+ 5 - 0
pro/logic/security.go

@@ -4,6 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"net/http"
+	"strings"
 
 	"github.com/gravitl/netmaker/logic"
 	"github.com/gravitl/netmaker/models"
@@ -173,6 +174,10 @@ func GlobalPermissionsCheck(username string, r *http.Request) error {
 	if (targetRsrc == models.HostRsrc.String() || targetRsrc == models.NetworkRsrc.String()) && r.Method == http.MethodGet && targetRsrcID == "" {
 		return nil
 	}
+	if targetRsrc == models.UserRsrc.String() && user.PlatformRoleID == models.PlatformUser && r.Method == http.MethodPut &&
+		strings.Contains(r.URL.Path, "/api/v1/users/add_network_user") || strings.Contains(r.URL.Path, "/api/v1/users/remove_network_user") {
+		return nil
+	}
 	if targetRsrc == models.UserRsrc.String() && username == targetRsrcID && (r.Method != http.MethodDelete) {
 		return nil
 	}

+ 142 - 34
pro/logic/user_mgmt.go

@@ -34,6 +34,13 @@ var PlatformUserUserPermissionTemplate = models.UserRolePermissionTemplate{
 	ID:         models.PlatformUser,
 	Default:    true,
 	FullAccess: false,
+	GlobalLevelAccess: map[models.RsrcType]map[models.RsrcID]models.RsrcPermissionScope{
+		models.UserRsrc: {
+			models.AllUserRsrcID: models.RsrcPermissionScope{
+				Read: true,
+			},
+		},
+	},
 }
 
 var NetworkAdminAllPermissionTemplate = models.UserRolePermissionTemplate{
@@ -590,7 +597,13 @@ func CreateUserGroup(g *models.UserGroup) error {
 	if err != nil {
 		return err
 	}
-	return database.Insert(g.ID.String(), string(d), database.USER_GROUPS_TABLE_NAME)
+	err = database.Insert(g.ID.String(), string(d), database.USER_GROUPS_TABLE_NAME)
+	if err != nil {
+		return err
+	}
+	// create default network gateway policies
+	CreateDefaultUserGroupNetworkPolicies(*g)
+	return nil
 }
 
 // GetUserGroup - fetches user group
@@ -655,11 +668,16 @@ func UpdateUserGroup(g models.UserGroup) error {
 	if err != nil {
 		return err
 	}
+
 	return database.Insert(g.ID.String(), string(d), database.USER_GROUPS_TABLE_NAME)
 }
 
 // DeleteUserGroup - deletes user group
 func DeleteUserGroup(gid models.UserGroupID) error {
+	g, err := GetUserGroup(gid)
+	if err != nil {
+		return err
+	}
 	users, err := logic.GetUsersDB()
 	if err != nil && !database.IsEmptyRecord(err) {
 		return err
@@ -668,6 +686,8 @@ func DeleteUserGroup(gid models.UserGroupID) error {
 		delete(user.UserGroups, gid)
 		logic.UpsertUser(user)
 	}
+	// create default network gateway policies
+	DeleteDefaultUserGroupNetworkPolicies(g)
 	return database.DeleteRecord(database.USER_GROUPS_TABLE_NAME, gid.String())
 }
 
@@ -738,7 +758,10 @@ func GetUserRAGNodes(user models.User) (gws map[string]models.Node) {
 			continue
 		}
 		if user.PlatformRoleID == models.AdminRole || user.PlatformRoleID == models.SuperAdminRole {
-			gws[node.ID.String()] = node
+			if ok, _ := IsUserAllowedToCommunicate(user.UserName, node); ok {
+				gws[node.ID.String()] = node
+				continue
+			}
 		} else {
 			// check if user has network role assigned
 			if roles, ok := user.NetworkRoles[models.NetworkID(node.Network)]; ok && len(roles) > 0 {
@@ -1079,27 +1102,14 @@ func UpdatesUserGwAccessOnRoleUpdates(currNetworkAccess,
 	}
 }
 
-func UpdatesUserGwAccessOnGrpUpdates(currNetworkRoles, changeNetworkRoles map[models.NetworkID]map[models.UserRoleID]struct{}) {
-	networkChangeMap := make(map[models.NetworkID]map[models.UserRoleID]struct{})
-	for netID, networkUserRoles := range currNetworkRoles {
-		if _, ok := changeNetworkRoles[netID]; !ok {
-			for netRoleID := range networkUserRoles {
-				if _, ok := networkChangeMap[netID]; !ok {
-					networkChangeMap[netID] = make(map[models.UserRoleID]struct{})
-				}
-				networkChangeMap[netID][netRoleID] = struct{}{}
-			}
-		} else {
-			for netRoleID := range networkUserRoles {
-				if _, ok := changeNetworkRoles[netID][netRoleID]; !ok {
-					if _, ok := networkChangeMap[netID]; !ok {
-						networkChangeMap[netID] = make(map[models.UserRoleID]struct{})
-					}
-					networkChangeMap[netID][netRoleID] = struct{}{}
-				}
-			}
+func UpdatesUserGwAccessOnGrpUpdates(groupID models.UserGroupID, oldNetworkRoles, newNetworkRoles map[models.NetworkID]map[models.UserRoleID]struct{}) {
+	networkRemovedMap := make(map[models.NetworkID]struct{})
+	for netID := range oldNetworkRoles {
+		if _, ok := newNetworkRoles[netID]; !ok {
+			networkRemovedMap[netID] = struct{}{}
 		}
 	}
+
 	extclients, err := logic.GetAllExtClients()
 	if err != nil {
 		slog.Error("failed to fetch extclients", "error", err)
@@ -1109,27 +1119,56 @@ func UpdatesUserGwAccessOnGrpUpdates(currNetworkRoles, changeNetworkRoles map[mo
 	if err != nil {
 		return
 	}
-	for _, extclient := range extclients {
 
-		if _, ok := networkChangeMap[models.NetworkID(extclient.Network)]; ok {
-			if user, ok := userMap[extclient.OwnerID]; ok {
-				if user.PlatformRoleID != models.ServiceUser {
-					continue
-				}
-				err = logic.DeleteExtClientAndCleanup(extclient)
-				if err != nil {
-					slog.Error("failed to delete extclient",
-						"id", extclient.ClientID, "owner", user.UserName, "error", err)
+	for _, extclient := range extclients {
+		var shouldDelete bool
+		user, ok := userMap[extclient.OwnerID]
+		if !ok {
+			// user does not exist, delete extclient.
+			shouldDelete = true
+		} else {
+			if user.PlatformRoleID == models.SuperAdminRole || user.PlatformRoleID == models.AdminRole {
+				// Super-admin and Admin's access is not determined by group membership
+				// or network roles. Even if a network is removed from the group, they
+				// continue to have access to the network.
+				// So, no need to delete the extclient.
+				shouldDelete = false
+			} else {
+				_, hasAccess := user.NetworkRoles[models.NetworkID(extclient.Network)]
+				if hasAccess {
+					// The user has access to the network by themselves and not by
+					// virtue of being a member of the group.
+					// So, no need to delete the extclient.
+					shouldDelete = false
 				} else {
-					if err := mq.PublishDeletedClientPeerUpdate(&extclient); err != nil {
-						slog.Error("error setting ext peers: " + err.Error())
+					_, userInGroup := user.UserGroups[groupID]
+					_, networkRemoved := networkRemovedMap[models.NetworkID(extclient.Network)]
+					if userInGroup && networkRemoved {
+						// This group no longer provides it's members access to the
+						// network.
+						// This user is a member of the group and has no direct
+						// access to the network (either by its platform role or by
+						// network roles).
+						// So, delete the extclient.
+						shouldDelete = true
 					}
 				}
 			}
-
 		}
 
+		if shouldDelete {
+			err = logic.DeleteExtClientAndCleanup(extclient)
+			if err != nil {
+				slog.Error("failed to delete extclient",
+					"id", extclient.ClientID, "owner", user.UserName, "error", err)
+			} else {
+				if err := mq.PublishDeletedClientPeerUpdate(&extclient); err != nil {
+					slog.Error("error setting ext peers: " + err.Error())
+				}
+			}
+		}
 	}
+
 	if servercfg.IsDNSMode() {
 		logic.SetDNS()
 	}
@@ -1209,6 +1248,75 @@ func UpdateUserGwAccess(currentUser, changeUser models.User) {
 
 }
 
+func CreateDefaultUserGroupNetworkPolicies(g models.UserGroup) {
+	for networkID := range g.NetworkRoles {
+		network, err := logic.GetNetwork(networkID.String())
+		if err != nil {
+			continue
+		}
+
+		acl := models.Acl{
+			ID:          uuid.New().String(),
+			Name:        fmt.Sprintf("%s group", g.Name),
+			MetaData:    "This Policy allows user group to communicate with all gateways",
+			Default:     true,
+			ServiceType: models.Any,
+			NetworkID:   models.NetworkID(network.NetID),
+			Proto:       models.ALL,
+			RuleType:    models.UserPolicy,
+			Src: []models.AclPolicyTag{
+				{
+					ID:    models.UserGroupAclID,
+					Value: g.ID.String(),
+				},
+			},
+			Dst: []models.AclPolicyTag{
+				{
+					ID:    models.NodeTagID,
+					Value: fmt.Sprintf("%s.%s", models.NetworkID(network.NetID), models.GwTagName),
+				}},
+			AllowedDirection: models.TrafficDirectionUni,
+			Enabled:          true,
+			CreatedBy:        "auto",
+			CreatedAt:        time.Now().UTC(),
+		}
+		logic.InsertAcl(acl)
+
+	}
+}
+
+func DeleteDefaultUserGroupNetworkPolicies(g models.UserGroup) {
+	for networkID := range g.NetworkRoles {
+		acls, err := logic.ListAclsByNetwork(networkID)
+		if err != nil {
+			continue
+		}
+
+		for _, acl := range acls {
+			var hasGroupSrc bool
+			newAclSrc := make([]models.AclPolicyTag, 0)
+			for _, src := range acl.Src {
+				if src.ID == models.UserGroupAclID && src.Value == g.ID.String() {
+					hasGroupSrc = true
+				} else {
+					newAclSrc = append(newAclSrc, src)
+				}
+			}
+
+			if hasGroupSrc {
+				if len(newAclSrc) == 0 {
+					// no other src exists, delete acl.
+					_ = logic.DeleteAcl(acl)
+				} else {
+					// other sources exist, update acl.
+					acl.Src = newAclSrc
+					_ = logic.UpsertAcl(acl)
+				}
+			}
+		}
+	}
+}
+
 func CreateDefaultUserPolicies(netID models.NetworkID) {
 	if netID.String() == "" {
 		return

+ 58 - 0
schema/dns.go

@@ -0,0 +1,58 @@
+package schema
+
+import (
+	"context"
+	"time"
+
+	"github.com/gravitl/netmaker/db"
+	"gorm.io/datatypes"
+)
+
+type Nameserver struct {
+	ID           string                      `gorm:"primaryKey" json:"id"`
+	Name         string                      `gorm:"name" json:"name"`
+	NetworkID    string                      `gorm:"network_id" json:"network_id"`
+	Description  string                      `gorm:"description" json:"description"`
+	Servers      datatypes.JSONSlice[string] `gorm:"servers" json:"servers"`
+	MatchAll     bool                        `gorm:"match_all" json:"match_all"`
+	MatchDomains datatypes.JSONSlice[string] `gorm:"match_domains" json:"match_domains"`
+	Tags         datatypes.JSONMap           `gorm:"tags" json:"tags"`
+	Nodes        datatypes.JSONMap           `gorm:"nodes" json:"nodes"`
+	Status       bool                        `gorm:"status" json:"status"`
+	CreatedBy    string                      `gorm:"created_by" json:"created_by"`
+	CreatedAt    time.Time                   `gorm:"created_at" json:"created_at"`
+	UpdatedAt    time.Time                   `gorm:"updated_at" json:"updated_at"`
+}
+
+func (ns *Nameserver) Get(ctx context.Context) error {
+	return db.FromContext(ctx).Model(&Nameserver{}).First(&ns).Where("id = ?", ns.ID).Error
+}
+
+func (ns *Nameserver) Update(ctx context.Context) error {
+	return db.FromContext(ctx).Model(&Nameserver{}).Where("id = ?", ns.ID).Updates(&ns).Error
+}
+
+func (ns *Nameserver) Create(ctx context.Context) error {
+	return db.FromContext(ctx).Model(&Nameserver{}).Create(&ns).Error
+}
+
+func (ns *Nameserver) ListByNetwork(ctx context.Context) (dnsli []Nameserver, err error) {
+	err = db.FromContext(ctx).Model(&Nameserver{}).Where("network_id = ?", ns.NetworkID).Find(&dnsli).Error
+	return
+}
+
+func (ns *Nameserver) Delete(ctx context.Context) error {
+	return db.FromContext(ctx).Model(&Nameserver{}).Where("id = ?", ns.ID).Delete(&ns).Error
+}
+
+func (ns *Nameserver) UpdateStatus(ctx context.Context) error {
+	return db.FromContext(ctx).Model(&Nameserver{}).Where("id = ?", ns.ID).Updates(map[string]any{
+		"status": ns.Status,
+	}).Error
+}
+
+func (ns *Nameserver) UpdateMatchAll(ctx context.Context) error {
+	return db.FromContext(ctx).Model(&Nameserver{}).Where("id = ?", ns.ID).Updates(map[string]any{
+		"match_all": ns.MatchAll,
+	}).Error
+}

+ 1 - 0
schema/models.go

@@ -8,5 +8,6 @@ func ListModels() []interface{} {
 		&UserAccessToken{},
 		&Event{},
 		&PendingHost{},
+		&Nameserver{},
 	}
 }