Browse Source

NET-1991: Add IDP sync functionality. (#3428)

* feat: api access tokens

* revoke all user tokens

* redefine access token api routes, add auto egress option to enrollment keys

* add server settings apis, add db table for settigs

* handle server settings updates

* switch to using settings from DB

* fix sever settings migration

* revet force migration for settings

* fix server settings database write

* fix revoked tokens to be unauthorized

* remove unused functions

* convert access token to sql schema

* switch access token to sql schema

* fix merge conflicts

* fix server settings types

* bypass basic auth setting for super admin

* add TODO comment

* feat(go): add types for idp package;

* feat(go): import azure sdk;

* feat(go): add stub for google workspace client;

* feat(go): implement azure ad client;

* feat(go): sync users and groups using idp client;

* publish peer update on settings update

* feat(go): read creds from env vars;

* feat(go): add api endpoint to trigger idp sync;

* fix(go): sync member changes;

* fix(go): handle error;

* fix(go): set correct response type;

* feat(go): support disabling user accounts;

1. Add api endpoints to enable and disable user accounts.
2. Add checks in authenticators to prevent disabled users from logging in.
3. Add checks in middleware to prevent api usage by disabled users.

* feat(go): use string slice for group members;

* feat(go): sync user account status from idp;

* feat(go): import google admin sdk;

* feat(go): add support for google workspace idp;

* feat(go): initialize idp client on sync;

* feat(go): sync from idp periodically;

* feat(go): improvements for google idp;

1. Use the impersonate package to authenticate.
2. Use Pages method to get all data.

* chore(go): import style changes from migration branch;

1. Singular file names for table schema.
2. No table name method.
3. Use .Model instead of .Table.
4. No unnecessary tagging.

* remove nat check on egress gateway request

* Revert "remove nat check on egress gateway request"

This reverts commit 0aff12a189828fc4ccb4594adf7a3eb8772560f2.

* feat(go): add db middleware;

* feat(go): restore method;

* feat(go): add user access token schema;

* fix user auth api:

* re initalise oauth and email config

* feat(go): fetch idp creds from server settings;

* feat(go): add filters for users and groups;

* feat(go): skip sync from idp if disabled;

* feat(go): add endpoint to remove idp integration;

* feat(go): import all users if no filters;

* feat(go): assign service-user role on sync;

* feat(go): remove microsoft-go-sdk;

* feat(go): add display name field for user;

* fix(go): set account disabled correctly;

* fix(go): update user if display name changes;

* fix(go): remove auth provider when removing idp integration;

* fix(go): ignore display name if empty;

* feat(go): add idp sync interval setting;

* fix(go): error on invalid auth provider;

* fix(go): no error if no user on group delete;

* fix(go): check superadmin using platform role id;

* feat(go): add display name and account disabled to return user as well;

* feat(go): tidy go mod after merge;

* feat(go): reinitialize auth provider and idp sync hook;

* fix(go): merge error;

* fix(go): merge error;

* feat(go): use id as the external provider id;

* fix(go): comments;

* feat(go): add function to return pending users;

* feat(go): prevent external id erasure;

* fix(go): user and group sync errors;

* chore(go): cleanup;

* fix(go): delete only oauth users;

* feat(go): use uuid group id;

* export ipd id to in rest api

* feat(go): don't use uuid for default groups;

* feat(go): migrate group only if id not uuid;

* chore(go): go mod tidy;

---------

Co-authored-by: abhishek9686 <[email protected]>
Co-authored-by: Abhishek K <[email protected]>
Co-authored-by: the_aceix <[email protected]>
Vishal Dalwadi 3 tháng trước cách đây
mục cha
commit
614cf77b5a

+ 2 - 1
controllers/server.go

@@ -297,9 +297,10 @@ func updateSettings(w http.ResponseWriter, r *http.Request) {
 func reInit(curr, new models.ServerSettings, force bool) {
 	logic.SettingsMutex.Lock()
 	defer logic.SettingsMutex.Unlock()
-	logic.InitializeAuthProvider()
+	logic.ResetAuthProvider()
 	logic.EmailInit()
 	logic.SetVerbosity(int(logic.GetServerSettings().Verbosity))
+	logic.ResetIDPSyncHook()
 	// check if auto update is changed
 	if force {
 		if curr.NetclientAutoUpdate != new.NetclientAutoUpdate {

+ 68 - 0
controllers/user.go

@@ -37,6 +37,8 @@ func userHandlers(r *mux.Router) {
 	r.HandleFunc("/api/users/{username}", logic.SecurityCheck(true, checkFreeTierLimits(limitChoiceUsers, http.HandlerFunc(createUser)))).Methods(http.MethodPost)
 	r.HandleFunc("/api/users/{username}", logic.SecurityCheck(true, http.HandlerFunc(deleteUser))).Methods(http.MethodDelete)
 	r.HandleFunc("/api/users/{username}", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(getUser)))).Methods(http.MethodGet)
+	r.HandleFunc("/api/users/{username}/enable", logic.SecurityCheck(true, http.HandlerFunc(enableUserAccount))).Methods(http.MethodPost)
+	r.HandleFunc("/api/users/{username}/disable", logic.SecurityCheck(true, http.HandlerFunc(disableUserAccount))).Methods(http.MethodPost)
 	r.HandleFunc("/api/v1/users", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(getUserV1)))).Methods(http.MethodGet)
 	r.HandleFunc("/api/users", logic.SecurityCheck(true, http.HandlerFunc(getUsers))).Methods(http.MethodGet)
 	r.HandleFunc("/api/v1/users/roles", logic.SecurityCheck(true, http.HandlerFunc(ListRoles))).Methods(http.MethodGet)
@@ -270,6 +272,13 @@ func authenticateUser(response http.ResponseWriter, request *http.Request) {
 		logic.ReturnErrorResponse(response, request, logic.FormatError(errors.New("user is registered via SSO"), "badrequest"))
 		return
 	}
+
+	if user.AccountDisabled {
+		err = errors.New("user account disabled")
+		logic.ReturnErrorResponse(response, request, logic.FormatError(err, "unauthorized"))
+		return
+	}
+
 	if !user.IsSuperAdmin && !logic.IsBasicAuthEnabled() {
 		logic.ReturnErrorResponse(
 			response,
@@ -446,6 +455,65 @@ func getUser(w http.ResponseWriter, r *http.Request) {
 	json.NewEncoder(w).Encode(user)
 }
 
+// @Summary     Enable a user's account
+// @Router      /api/users/{username}/enable [post]
+// @Tags        Users
+// @Param       username path string true "Username of the user to enable"
+// @Success     200 {object} models.SuccessResponse
+// @Failure     400 {object} models.ErrorResponse
+// @Failure     500 {object} models.ErrorResponse
+func enableUserAccount(w http.ResponseWriter, r *http.Request) {
+	username := mux.Vars(r)["username"]
+	user, err := logic.GetUser(username)
+	if err != nil {
+		logger.Log(0, "failed to fetch user: ", err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		return
+	}
+
+	user.AccountDisabled = false
+	err = logic.UpsertUser(*user)
+	if err != nil {
+		logger.Log(0, "failed to enable user account: ", err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+	}
+
+	logic.ReturnSuccessResponse(w, r, "user account enabled")
+}
+
+// @Summary     Disable a user's account
+// @Router      /api/users/{username}/disable [post]
+// @Tags        Users
+// @Param       username path string true "Username of the user to disable"
+// @Success     200 {object} models.SuccessResponse
+// @Failure     400 {object} models.ErrorResponse
+// @Failure     500 {object} models.ErrorResponse
+func disableUserAccount(w http.ResponseWriter, r *http.Request) {
+	username := mux.Vars(r)["username"]
+	user, err := logic.GetUser(username)
+	if err != nil {
+		logger.Log(0, "failed to fetch user: ", err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+		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"))
+		return
+	}
+
+	user.AccountDisabled = true
+	err = logic.UpsertUser(*user)
+	if err != nil {
+		logger.Log(0, "failed to disable user account: ", err.Error())
+		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
+	}
+
+	logic.ReturnSuccessResponse(w, r, "user account disabled")
+}
+
 // swagger:route GET /api/v1/users user getUserV1
 //
 // Get an individual user with role info.

+ 0 - 2
database/database.go

@@ -19,8 +19,6 @@ const (
 	DELETED_NODES_TABLE_NAME = "deletednodes"
 	// USERS_TABLE_NAME - users table
 	USERS_TABLE_NAME = "users"
-	// ACCESS_TOKENS_TABLE_NAME - access tokens table
-	ACCESS_TOKENS_TABLE_NAME = "user_access_tokens"
 	// USER_PERMISSIONS_TABLE_NAME - user permissions table
 	USER_PERMISSIONS_TABLE_NAME = "user_permissions"
 	// CERTS_TABLE_NAME - certificates table

+ 21 - 6
go.mod

@@ -6,7 +6,7 @@ toolchain go1.23.7
 
 require (
 	github.com/blang/semver v3.5.1+incompatible
-	github.com/eclipse/paho.mqtt.golang v1.4.3
+	github.com/eclipse/paho.mqtt.golang v1.5.0
 	github.com/go-playground/validator/v10 v10.26.0
 	github.com/golang-jwt/jwt/v4 v4.5.2
 	github.com/google/uuid v1.6.0
@@ -21,7 +21,7 @@ require (
 	github.com/txn2/txeh v1.5.5
 	go.uber.org/automaxprocs v1.6.0
 	golang.org/x/crypto v0.38.0
-	golang.org/x/net v0.37.0 // indirect
+	golang.org/x/net v0.39.0 // indirect
 	golang.org/x/oauth2 v0.29.0
 	golang.org/x/sys v0.33.0 // indirect
 	golang.org/x/text v0.25.0 // indirect
@@ -42,11 +42,13 @@ require (
 )
 
 require (
+	github.com/google/go-cmp v0.7.0
 	github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e
 	github.com/guumaster/tablewriter v0.0.10
 	github.com/matryer/is v1.4.1
 	github.com/olekukonko/tablewriter v0.0.5
 	github.com/spf13/cobra v1.9.1
+	google.golang.org/api v0.229.0
 	gopkg.in/mail.v2 v2.3.1
 	gorm.io/datatypes v1.2.5
 	gorm.io/driver/postgres v1.5.11
@@ -55,11 +57,17 @@ require (
 )
 
 require (
-	cloud.google.com/go/compute/metadata v0.3.0 // indirect
+	cloud.google.com/go/auth v0.16.0 // indirect
+	cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
+	cloud.google.com/go/compute/metadata v0.6.0 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
 	github.com/go-jose/go-jose/v4 v4.0.5 // indirect
+	github.com/go-logr/logr v1.4.2 // indirect
+	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-sql-driver/mysql v1.8.1 // indirect
-	github.com/google/go-cmp v0.7.0 // indirect
+	github.com/google/s2a-go v0.1.9 // indirect
+	github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
+	github.com/googleapis/gax-go/v2 v2.14.1 // indirect
 	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -68,18 +76,25 @@ require (
 	github.com/jackc/puddle/v2 v2.2.2 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect
-	github.com/kr/text v0.2.0 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/rogpeppe/go-internal v1.14.1 // indirect
 	github.com/seancfoley/bintree v1.3.1 // indirect
 	github.com/spf13/pflag v1.0.6 // indirect
+	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
+	go.opentelemetry.io/otel v1.35.0 // indirect
+	go.opentelemetry.io/otel/metric v1.35.0 // indirect
+	go.opentelemetry.io/otel/trace v1.35.0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e // indirect
+	google.golang.org/grpc v1.71.1 // indirect
+	google.golang.org/protobuf v1.36.6 // indirect
 	gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
 	gorm.io/driver/mysql v1.5.6 // indirect
 )
 
 require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
-	github.com/felixge/httpsnoop v1.0.3 // indirect
+	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/hashicorp/go-version v1.7.0

+ 55 - 13
go.sum

@@ -1,5 +1,9 @@
-cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
-cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
+cloud.google.com/go/auth v0.16.0 h1:Pd8P1s9WkcrBE2n/PhAwKsdrR35V3Sg2II9B+ndM3CU=
+cloud.google.com/go/auth v0.16.0/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
+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.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
+cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
 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=
@@ -9,18 +13,22 @@ github.com/c-robinson/iplib v1.0.8/go.mod h1:i3LuuFL1hRT5gFpBRnEydzw8R6yhGkF4szN
 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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 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=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
-github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
-github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
-github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/eclipse/paho.mqtt.golang v1.5.0 h1:EH+bUVJNgttidWFkLLVKaQPGmkTUfQQqjOsyvMGvD6o=
+github.com/eclipse/paho.mqtt.golang v1.5.0/go.mod h1:du/2qNQVqJf/Sqs4MEL77kR8QTqANF7XU7Fk0aOTAgk=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
 github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
 github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
 github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
 github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -38,12 +46,18 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt
 github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
 github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
 github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
+github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
+github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
+github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
+github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
 github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e h1:XmA6L9IPRdUr28a+SK/oMchGgQy159wvzXA5tJ7l+40=
 github.com/goombaio/namegenerator v0.0.0-20181006234301-989e774b106e/go.mod h1:AFIo+02s+12CEg8Gzz9kzhCbmbq6JcKNrhHffCGA9z4=
 github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
@@ -72,8 +86,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
 github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
 github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
 github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
-github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
-github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@@ -123,14 +137,30 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/txn2/txeh v1.5.5 h1:UN4e/lCK5HGw/gGAi2GCVrNKg0GTCUWs7gs5riaZlz4=
 github.com/txn2/txeh v1.5.5/go.mod h1:qYzGG9kCzeVEI12geK4IlanHWY8X4uy/I3NcW7mk8g4=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
+go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
+go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
+go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
+go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
+go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
+go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
+go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
+go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
+go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
+go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
 go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
 go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
 golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
 golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
 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/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
-golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
+golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
+golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
 golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
 golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
 golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
@@ -139,8 +169,20 @@ golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
 golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
 golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
+golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
+golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 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.229.0 h1:p98ymMtqeJ5i3lIBMj5MpR9kzIIgzpHHh8vQ+vgAzx8=
+google.golang.org/api v0.229.0/go.mod h1:wyDfmq5g1wYJWn29O22FDWN48P7Xcz0xz+LBpptYvB0=
+google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24=
+google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e h1:ztQaXfzEXTmCBvbtWYRhJxW+0iJcz2qXfd38/e9l7bA=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250414145226-207652e42e2e/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
+google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/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=

+ 49 - 3
logic/auth.go

@@ -8,15 +8,16 @@ import (
 	"fmt"
 	"time"
 
+	"github.com/gravitl/netmaker/db"
+	"github.com/gravitl/netmaker/schema"
+
 	"github.com/go-playground/validator/v10"
 	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/exp/slog"
 
 	"github.com/gravitl/netmaker/database"
-	"github.com/gravitl/netmaker/db"
 	"github.com/gravitl/netmaker/logger"
 	"github.com/gravitl/netmaker/models"
-	"github.com/gravitl/netmaker/schema"
 )
 
 const (
@@ -31,7 +32,8 @@ func ClearSuperUserCache() {
 	superUser = models.User{}
 }
 
-var InitializeAuthProvider = func() string { return "" }
+var ResetAuthProvider = func() {}
+var ResetIDPSyncHook = func() {}
 
 // HasSuperAdmin - checks if server has an superadmin/owner
 func HasSuperAdmin() (bool, error) {
@@ -303,11 +305,55 @@ func UpdateUser(userchange, user *models.User) (*models.User, error) {
 	if err := IsNetworkRolesValid(userchange.NetworkRoles); err != nil {
 		return userchange, errors.New("invalid network roles: " + err.Error())
 	}
+
+	if userchange.DisplayName != "" {
+		if user.ExternalIdentityProviderID != "" &&
+			user.DisplayName != userchange.DisplayName {
+			return userchange, errors.New("display name cannot be updated for external user")
+		}
+
+		user.DisplayName = userchange.DisplayName
+	}
+
+	if user.ExternalIdentityProviderID != "" &&
+		userchange.AccountDisabled != user.AccountDisabled {
+		return userchange, errors.New("account status cannot be updated for external user")
+	}
+
 	// Reset Gw Access for service users
 	go UpdateUserGwAccess(*user, *userchange)
 	if userchange.PlatformRoleID != "" {
 		user.PlatformRoleID = userchange.PlatformRoleID
 	}
+
+	for groupID := range userchange.UserGroups {
+		_, ok := user.UserGroups[groupID]
+		if !ok {
+			group, err := GetUserGroup(groupID)
+			if err != nil {
+				return userchange, err
+			}
+
+			if group.ExternalIdentityProviderID != "" {
+				return userchange, errors.New("cannot modify membership of external groups")
+			}
+		}
+	}
+
+	for groupID := range user.UserGroups {
+		_, ok := userchange.UserGroups[groupID]
+		if !ok {
+			group, err := GetUserGroup(groupID)
+			if err != nil {
+				return userchange, err
+			}
+
+			if group.ExternalIdentityProviderID != "" {
+				return userchange, errors.New("cannot modify membership of external groups")
+			}
+		}
+	}
+
 	user.UserGroups = userchange.UserGroups
 	user.NetworkRoles = userchange.NetworkRoles
 	AddGlobalNetRolesToAdmins(user)

+ 2 - 0
logic/jwts.go

@@ -163,9 +163,11 @@ func GetUserNameFromToken(authtoken string) (username string, err error) {
 // VerifyUserToken func will used to Verify the JWT Token while using APIS
 func VerifyUserToken(tokenString string) (username string, issuperadmin, isadmin bool, err error) {
 	claims := &models.UserClaims{}
+
 	if tokenString == servercfg.GetMasterKey() && servercfg.GetMasterKey() != "" {
 		return MasterUser, true, true, nil
 	}
+
 	token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
 		return jwtSecretKey, nil
 	})

+ 14 - 0
logic/security.go

@@ -1,6 +1,7 @@
 package logic
 
 import (
+	"errors"
 	"net/http"
 	"strings"
 
@@ -32,6 +33,19 @@ func SecurityCheck(reqAdmin bool, next http.Handler) http.HandlerFunc {
 			ReturnErrorResponse(w, r, FormatError(err, "unauthorized"))
 			return
 		}
+
+		user, err := GetUser(username)
+		if err != nil {
+			ReturnErrorResponse(w, r, FormatError(err, "unauthorized"))
+			return
+		}
+
+		if user.AccountDisabled {
+			err = errors.New("user account disabled")
+			ReturnErrorResponse(w, r, FormatError(err, "unauthorized"))
+			return
+		}
+
 		// detect masteradmin
 		if username == MasterUser {
 			r.Header.Set("ismaster", "yes")

+ 20 - 0
logic/settings.go

@@ -272,6 +272,26 @@ func GetAzureTenant() string {
 	return GetServerSettings().AzureTenant
 }
 
+// IsSyncEnabled returns whether auth provider sync is enabled.
+func IsSyncEnabled() bool {
+	return GetServerSettings().SyncEnabled
+}
+
+// GetIDPSyncInterval returns the interval at which the netmaker should sync
+// data from IDP.
+func GetIDPSyncInterval() time.Duration {
+	syncInterval, err := time.ParseDuration(GetServerSettings().IDPSyncInterval)
+	if err != nil {
+		return 24 * time.Hour
+	}
+
+	if syncInterval == 0 {
+		return 24 * time.Hour
+	}
+
+	return syncInterval
+}
+
 // GetMetricsPort - get metrics port
 func GetMetricsPort() int {
 	return GetServerSettings().MetricsPort

+ 2 - 0
logic/user_mgmt.go

@@ -50,6 +50,8 @@ var MigrateUserRoleAndGroups = func(u models.User) {
 
 }
 
+var MigrateGroups = func() {}
+
 var UpdateUserGwAccess = func(currentUser, changeUser models.User) {}
 
 var UpdateRole = func(r models.UserRolePermissionTemplate) error { return nil }

+ 27 - 9
logic/users.go

@@ -41,13 +41,15 @@ func GetReturnUser(username string) (models.ReturnUser, error) {
 // ToReturnUser - gets a user as a return user
 func ToReturnUser(user models.User) models.ReturnUser {
 	return models.ReturnUser{
-		UserName:       user.UserName,
-		PlatformRoleID: user.PlatformRoleID,
-		AuthType:       user.AuthType,
-		UserGroups:     user.UserGroups,
-		NetworkRoles:   user.NetworkRoles,
-		RemoteGwIDs:    user.RemoteGwIDs,
-		LastLoginTime:  user.LastLoginTime,
+		UserName:        user.UserName,
+		DisplayName:     user.DisplayName,
+		AccountDisabled: user.AccountDisabled,
+		AuthType:        user.AuthType,
+		RemoteGwIDs:     user.RemoteGwIDs,
+		UserGroups:      user.UserGroups,
+		PlatformRoleID:  user.PlatformRoleID,
+		NetworkRoles:    user.NetworkRoles,
+		LastLoginTime:   user.LastLoginTime,
 	}
 }
 
@@ -78,7 +80,7 @@ func GetSuperAdmin() (models.ReturnUser, error) {
 		return models.ReturnUser{}, err
 	}
 	for _, user := range users {
-		if user.IsSuperAdmin {
+		if user.IsSuperAdmin || user.PlatformRoleID == models.SuperAdminRole {
 			return user, nil
 		}
 	}
@@ -113,7 +115,7 @@ func IsPendingUser(username string) bool {
 	return false
 }
 
-func ListPendingUsers() ([]models.ReturnUser, error) {
+func ListPendingReturnUsers() ([]models.ReturnUser, error) {
 	pendingUsers := []models.ReturnUser{}
 	records, err := database.FetchRecords(database.PENDING_USERS_TABLE_NAME)
 	if err != nil && !database.IsEmptyRecord(err) {
@@ -129,6 +131,22 @@ func ListPendingUsers() ([]models.ReturnUser, error) {
 	return pendingUsers, nil
 }
 
+func ListPendingUsers() ([]models.User, error) {
+	var pendingUsers []models.User
+	records, err := database.FetchRecords(database.PENDING_USERS_TABLE_NAME)
+	if err != nil && !database.IsEmptyRecord(err) {
+		return pendingUsers, err
+	}
+	for _, record := range records {
+		var u models.User
+		err = json.Unmarshal([]byte(record), &u)
+		if err == nil {
+			pendingUsers = append(pendingUsers, u)
+		}
+	}
+	return pendingUsers, nil
+}
+
 func GetUserMap() (map[string]models.User, error) {
 	userMap := make(map[string]models.User)
 	records, err := database.FetchRecords(database.USERS_TABLE_NAME)

+ 5 - 0
migrate/migrate.go

@@ -29,6 +29,7 @@ func Run() {
 	assignSuperAdmin()
 	createDefaultTagsAndPolicies()
 	removeOldUserGrps()
+	syncGroups()
 	syncUsers()
 	updateHosts()
 	updateNodes()
@@ -393,6 +394,10 @@ func MigrateEmqx() {
 
 }
 
+func syncGroups() {
+	logic.MigrateGroups()
+}
+
 func syncUsers() {
 	// create default network user roles for existing networks
 	if servercfg.IsPro {

+ 6 - 0
models/settings.go

@@ -15,7 +15,13 @@ type ServerSettings struct {
 	OIDCIssuer                 string `json:"oidcissuer"`
 	ClientID                   string `json:"client_id"`
 	ClientSecret               string `json:"client_secret"`
+	SyncEnabled                bool     `json:"sync_enabled"`
+	GoogleAdminEmail           string   `json:"google_admin_email"`
+	GoogleSACredsJson          string   `json:"google_sa_creds_json"`
 	AzureTenant                string `json:"azure_tenant"`
+	UserFilters                []string `json:"user_filters"`
+	GroupFilters               []string `json:"group_filters"`
+	IDPSyncInterval            string   `json:"idp_sync_interval"`
 	Telemetry                  string `json:"telemetry"`
 	BasicAuth                  bool   `json:"basic_auth"`
 	JwtValidityDuration        int    `json:"jwt_validity_duration"`

+ 20 - 14
models/user_mgmt.go

@@ -144,17 +144,20 @@ type CreateGroupReq struct {
 }
 
 type UserGroup struct {
-	ID           UserGroupID                           `json:"id"`
-	Default      bool                                  `json:"default"`
-	Name         string                                `json:"name"`
-	NetworkRoles map[NetworkID]map[UserRoleID]struct{} `json:"network_roles"`
-	MetaData     string                                `json:"meta_data"`
+	ID                         UserGroupID                           `json:"id"`
+	ExternalIdentityProviderID string                                `json:"external_identity_provider_id"`
+	Default                    bool                                  `json:"default"`
+	Name                       string                                `json:"name"`
+	NetworkRoles               map[NetworkID]map[UserRoleID]struct{} `json:"network_roles"`
+	MetaData                   string                                `json:"meta_data"`
 }
 
 // User struct - struct for Users
 type User struct {
 	UserName                   string                                `json:"username" bson:"username" validate:"min=3,in_charset|email"`
 	ExternalIdentityProviderID string                                `json:"external_identity_provider_id"`
+	DisplayName                string                                `json:"display_name"`
+	AccountDisabled            bool                                  `json:"account_disabled"`
 	Password                   string                                `json:"password" bson:"password" validate:"required,min=5"`
 	IsAdmin                    bool                                  `json:"isadmin" bson:"isadmin"` // deprecated
 	IsSuperAdmin               bool                                  `json:"issuperadmin"`           // deprecated
@@ -174,15 +177,18 @@ type ReturnUserWithRolesAndGroups struct {
 
 // ReturnUser - return user struct
 type ReturnUser struct {
-	UserName       string                                `json:"username"`
-	IsAdmin        bool                                  `json:"isadmin"`
-	IsSuperAdmin   bool                                  `json:"issuperadmin"`
-	AuthType       AuthType                              `json:"auth_type"`
-	RemoteGwIDs    map[string]struct{}                   `json:"remote_gw_ids"` // deprecated
-	UserGroups     map[UserGroupID]struct{}              `json:"user_group_ids"`
-	PlatformRoleID UserRoleID                            `json:"platform_role_id"`
-	NetworkRoles   map[NetworkID]map[UserRoleID]struct{} `json:"network_roles"`
-	LastLoginTime  time.Time                             `json:"last_login_time"`
+	UserName                   string                                `json:"username"`
+	ExternalIdentityProviderID string                                `json:"external_identity_provider_id"`
+	DisplayName                string                                `json:"display_name"`
+	AccountDisabled            bool                                  `json:"account_disabled"`
+	IsAdmin                    bool                                  `json:"isadmin"`
+	IsSuperAdmin               bool                                  `json:"issuperadmin"`
+	AuthType                   AuthType                              `json:"auth_type"`
+	RemoteGwIDs                map[string]struct{}                   `json:"remote_gw_ids"` // deprecated
+	UserGroups                 map[UserGroupID]struct{}              `json:"user_group_ids"`
+	PlatformRoleID             UserRoleID                            `json:"platform_role_id"`
+	NetworkRoles               map[NetworkID]map[UserRoleID]struct{} `json:"network_roles"`
+	LastLoginTime              time.Time                             `json:"last_login_time"`
 }
 
 // UserAuthParams - user auth params struct

+ 12 - 0
pro/auth/auth.go

@@ -34,6 +34,7 @@ const (
 
 // OAuthUser - generic OAuth strategy user
 type OAuthUser struct {
+	ID                string `json:"id" bson:"id"`
 	Name              string `json:"name" bson:"name"`
 	Email             string `json:"email" bson:"email"`
 	Login             string `json:"login" bson:"login"`
@@ -63,6 +64,17 @@ func getCurrentAuthFunctions() map[string]interface{} {
 	}
 }
 
+// ResetAuthProvider resets the auth provider configuration.
+func ResetAuthProvider() {
+	settings := logic.GetServerSettings()
+
+	if settings.AuthProvider == "" {
+		auth_provider = nil
+	}
+
+	InitializeAuthProvider()
+}
+
 // InitializeAuthProvider - initializes the auth provider if any is present
 func InitializeAuthProvider() string {
 	var functions = getCurrentAuthFunctions()

+ 10 - 2
pro/auth/azure-ad.go

@@ -111,7 +111,7 @@ func handleAzureCallback(w http.ResponseWriter, r *http.Request) {
 					logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 					return
 				}
-				user.ExternalIdentityProviderID = content.UserPrincipalName
+				user.ExternalIdentityProviderID = content.ID
 				if err = logic.CreateUser(&user); err != nil {
 					handleSomethingWentWrong(w)
 					return
@@ -124,7 +124,9 @@ func handleAzureCallback(w http.ResponseWriter, r *http.Request) {
 					return
 				}
 				err = logic.InsertPendingUser(&models.User{
-					UserName: content.Email,
+					UserName:                   content.Email,
+					ExternalIdentityProviderID: content.ID,
+					AuthType:                   models.OAuth,
 				})
 				if err != nil {
 					handleSomethingWentWrong(w)
@@ -152,6 +154,12 @@ func handleAzureCallback(w http.ResponseWriter, r *http.Request) {
 		handleOauthUserNotFound(w)
 		return
 	}
+
+	if user.AccountDisabled {
+		handleUserAccountDisabled(w)
+		return
+	}
+
 	userRole, err := logic.GetRole(user.PlatformRoleID)
 	if err != nil {
 		handleSomethingWentWrong(w)

+ 8 - 0
pro/auth/error.go

@@ -113,6 +113,8 @@ var notallowedtosignup = fmt.Sprintf(htmlBaseTemplate, `<h2>Your email is not al
 var authTypeMismatch = fmt.Sprintf(htmlBaseTemplate, `<h2>It looks like you already have an account with us using Basic Authentication.</h2>
 <p>To continue, please log in with your existing credentials or reset your password if needed.</p>`)
 
+var userAccountDisabled = fmt.Sprintf(htmlBaseTemplate, `<h2>Your account has been disabled. Please contact your administrator for more information about your account.</h2>`)
+
 func handleOauthUserNotFound(response http.ResponseWriter) {
 	response.Header().Set("Content-Type", "text/html; charset=utf-8")
 	response.WriteHeader(http.StatusNotFound)
@@ -166,3 +168,9 @@ func handleAuthTypeMismatch(response http.ResponseWriter) {
 	response.WriteHeader(http.StatusBadRequest)
 	response.Write([]byte(authTypeMismatch))
 }
+
+func handleUserAccountDisabled(response http.ResponseWriter) {
+	response.Header().Set("Content-Type", "text/html; charset=utf-8")
+	response.WriteHeader(http.StatusUnauthorized)
+	response.Write([]byte(userAccountDisabled))
+}

+ 10 - 2
pro/auth/github.go

@@ -111,7 +111,7 @@ func handleGithubCallback(w http.ResponseWriter, r *http.Request) {
 					logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 					return
 				}
-				user.ExternalIdentityProviderID = content.Login
+				user.ExternalIdentityProviderID = content.ID
 				if err = logic.CreateUser(&user); err != nil {
 					handleSomethingWentWrong(w)
 					return
@@ -124,7 +124,9 @@ func handleGithubCallback(w http.ResponseWriter, r *http.Request) {
 					return
 				}
 				err = logic.InsertPendingUser(&models.User{
-					UserName: content.Email,
+					UserName:                   content.Email,
+					ExternalIdentityProviderID: content.ID,
+					AuthType:                   models.OAuth,
 				})
 				if err != nil {
 					handleSomethingWentWrong(w)
@@ -143,6 +145,12 @@ func handleGithubCallback(w http.ResponseWriter, r *http.Request) {
 		handleOauthUserNotFound(w)
 		return
 	}
+
+	if user.AccountDisabled {
+		handleUserAccountDisabled(w)
+		return
+	}
+
 	userRole, err := logic.GetRole(user.PlatformRoleID)
 	if err != nil {
 		handleSomethingWentWrong(w)

+ 8 - 1
pro/auth/google.go

@@ -105,7 +105,9 @@ func handleGoogleCallback(w http.ResponseWriter, r *http.Request) {
 					return
 				}
 				err = logic.InsertPendingUser(&models.User{
-					UserName: content.Email,
+					UserName:                   content.Email,
+					ExternalIdentityProviderID: content.ID,
+					AuthType:                   models.OAuth,
 				})
 				if err != nil {
 					handleSomethingWentWrong(w)
@@ -136,6 +138,11 @@ func handleGoogleCallback(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	if user.AccountDisabled {
+		handleUserAccountDisabled(w)
+		return
+	}
+
 	userRole, err := logic.GetRole(user.PlatformRoleID)
 	if err != nil {
 		handleSomethingWentWrong(w)

+ 3 - 1
pro/auth/headless_callback.go

@@ -64,7 +64,9 @@ func HandleHeadlessSSOCallback(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		if database.IsEmptyRecord(err) { // user must not exist, so try to make one
 			err = logic.InsertPendingUser(&models.User{
-				UserName: userClaims.getUserName(),
+				UserName:                   userClaims.getUserName(),
+				ExternalIdentityProviderID: userClaims.ID,
+				AuthType:                   models.OAuth,
 			})
 			if err != nil {
 				handleSomethingWentWrong(w)

+ 12 - 2
pro/auth/oidc.go

@@ -102,7 +102,7 @@ func handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
 					logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
 					return
 				}
-				user.ExternalIdentityProviderID = content.Email
+				user.ExternalIdentityProviderID = content.ID
 				if err = logic.CreateUser(&user); err != nil {
 					handleSomethingWentWrong(w)
 					return
@@ -115,7 +115,9 @@ func handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
 					return
 				}
 				err = logic.InsertPendingUser(&models.User{
-					UserName: content.Email,
+					UserName:                   content.Email,
+					ExternalIdentityProviderID: content.ID,
+					AuthType:                   models.OAuth,
 				})
 				if err != nil {
 					handleSomethingWentWrong(w)
@@ -143,6 +145,12 @@ func handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
 		handleOauthUserNotFound(w)
 		return
 	}
+
+	if user.AccountDisabled {
+		handleUserAccountDisabled(w)
+		return
+	}
+
 	userRole, err := logic.GetRole(user.PlatformRoleID)
 	if err != nil {
 		handleSomethingWentWrong(w)
@@ -224,6 +232,8 @@ func getOIDCUserInfo(state string, code string) (u *OAuthUser, e error) {
 		e = fmt.Errorf("error when claiming OIDCUser: \"%s\"", err.Error())
 	}
 
+	u.ID = idToken.Subject
+
 	return
 }
 

+ 281 - 0
pro/auth/sync.go

@@ -0,0 +1,281 @@
+package auth
+
+import (
+	"fmt"
+	"github.com/gravitl/netmaker/database"
+	"github.com/gravitl/netmaker/logger"
+	"github.com/gravitl/netmaker/logic"
+	"github.com/gravitl/netmaker/models"
+	"github.com/gravitl/netmaker/pro/idp"
+	"github.com/gravitl/netmaker/pro/idp/azure"
+	"github.com/gravitl/netmaker/pro/idp/google"
+	proLogic "github.com/gravitl/netmaker/pro/logic"
+	"strings"
+	"time"
+)
+
+var syncTicker *time.Ticker
+
+func StartSyncHook() {
+	syncTicker = time.NewTicker(logic.GetIDPSyncInterval())
+
+	for range syncTicker.C {
+		err := SyncFromIDP()
+		if err != nil {
+			logger.Log(0, "failed to sync from idp: ", err.Error())
+		} else {
+			logger.Log(0, "sync from idp complete")
+		}
+	}
+}
+
+func ResetIDPSyncHook() {
+	if syncTicker != nil {
+		syncTicker.Stop()
+		if logic.IsSyncEnabled() {
+			go StartSyncHook()
+		}
+	}
+}
+
+func SyncFromIDP() error {
+	settings := logic.GetServerSettings()
+
+	var idpClient idp.Client
+	var idpUsers []idp.User
+	var idpGroups []idp.Group
+	var err error
+
+	switch settings.AuthProvider {
+	case "google":
+		idpClient, err = google.NewGoogleWorkspaceClient()
+		if err != nil {
+			return err
+		}
+	case "azure-ad":
+		idpClient = azure.NewAzureEntraIDClient()
+	default:
+		if settings.AuthProvider != "" {
+			return fmt.Errorf("invalid auth provider: %s", settings.AuthProvider)
+		}
+	}
+
+	if settings.AuthProvider != "" && idpClient != nil {
+		idpUsers, err = idpClient.GetUsers()
+		if err != nil {
+			return err
+		}
+
+		idpGroups, err = idpClient.GetGroups()
+		if err != nil {
+			return err
+		}
+	}
+
+	err = syncUsers(idpUsers)
+	if err != nil {
+		return err
+	}
+
+	return syncGroups(idpGroups)
+}
+
+func syncUsers(idpUsers []idp.User) error {
+	dbUsers, err := logic.GetUsersDB()
+	if err != nil && !database.IsEmptyRecord(err) {
+		return err
+	}
+
+	password, err := logic.FetchPassValue("")
+	if err != nil {
+		return err
+	}
+
+	idpUsersMap := make(map[string]struct{})
+	for _, user := range idpUsers {
+		idpUsersMap[user.Username] = struct{}{}
+	}
+
+	dbUsersMap := make(map[string]models.User)
+	for _, user := range dbUsers {
+		dbUsersMap[user.UserName] = user
+	}
+
+	filters := logic.GetServerSettings().UserFilters
+
+	for _, user := range idpUsers {
+		var found bool
+		for _, filter := range filters {
+			if strings.HasPrefix(user.Username, filter) {
+				found = true
+				break
+			}
+		}
+
+		// if there are filters but none of them match, then skip this user.
+		if len(filters) > 0 && !found {
+			continue
+		}
+
+		dbUser, ok := dbUsersMap[user.Username]
+		if !ok {
+			// create the user only if it doesn't exist.
+			err = logic.CreateUser(&models.User{
+				UserName:                   user.Username,
+				ExternalIdentityProviderID: user.ID,
+				DisplayName:                user.DisplayName,
+				AccountDisabled:            user.AccountDisabled,
+				Password:                   password,
+				AuthType:                   models.OAuth,
+				PlatformRoleID:             models.ServiceUser,
+			})
+			if err != nil {
+				return err
+			}
+		} else if dbUser.AuthType == models.OAuth {
+			if dbUser.AccountDisabled != user.AccountDisabled ||
+				dbUser.DisplayName != user.DisplayName ||
+				dbUser.ExternalIdentityProviderID != user.ID {
+
+				dbUser.AccountDisabled = user.AccountDisabled
+				dbUser.DisplayName = user.DisplayName
+				dbUser.ExternalIdentityProviderID = user.ID
+
+				err = logic.UpsertUser(dbUser)
+				if err != nil {
+					return err
+				}
+			}
+		} else {
+			logger.Log(0, "user with username "+user.Username+" already exists, skipping creation")
+			continue
+		}
+	}
+
+	for _, user := range dbUsersMap {
+		if user.ExternalIdentityProviderID == "" {
+			continue
+		}
+		if _, ok := idpUsersMap[user.UserName]; !ok {
+			// delete the user if it has been deleted on idp.
+			err = logic.DeleteUser(user.UserName)
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+func syncGroups(idpGroups []idp.Group) error {
+	dbGroups, err := proLogic.ListUserGroups()
+	if err != nil && !database.IsEmptyRecord(err) {
+		return err
+	}
+
+	dbUsers, err := logic.GetUsersDB()
+	if err != nil && !database.IsEmptyRecord(err) {
+		return err
+	}
+
+	idpGroupsMap := make(map[string]struct{})
+	for _, group := range idpGroups {
+		idpGroupsMap[group.ID] = struct{}{}
+	}
+
+	dbGroupsMap := make(map[string]models.UserGroup)
+	for _, group := range dbGroups {
+		if group.ExternalIdentityProviderID != "" {
+			dbGroupsMap[group.ExternalIdentityProviderID] = group
+		}
+	}
+
+	dbUsersMap := make(map[string]models.User)
+	for _, user := range dbUsers {
+		if user.ExternalIdentityProviderID != "" {
+			dbUsersMap[user.ExternalIdentityProviderID] = user
+		}
+	}
+
+	modifiedUsers := make(map[string]struct{})
+
+	filters := logic.GetServerSettings().GroupFilters
+
+	for _, group := range idpGroups {
+		var found bool
+		for _, filter := range filters {
+			if strings.HasPrefix(group.Name, filter) {
+				found = true
+				break
+			}
+		}
+
+		// if there are filters but none of them match, then skip this group.
+		if len(filters) > 0 && !found {
+			continue
+		}
+
+		dbGroup, ok := dbGroupsMap[group.ID]
+		if !ok {
+			err := proLogic.CreateUserGroup(models.UserGroup{
+				ExternalIdentityProviderID: group.ID,
+				Default:                    false,
+				Name:                       group.Name,
+			})
+			if err != nil {
+				return err
+			}
+		} else {
+			dbGroup.Name = group.Name
+			err = proLogic.UpdateUserGroup(dbGroup)
+			if err != nil {
+				return err
+			}
+		}
+
+		groupMembersMap := make(map[string]struct{})
+		for _, member := range group.Members {
+			groupMembersMap[member] = struct{}{}
+		}
+
+		for _, user := range dbUsers {
+			// use dbGroup.Name because the group name may have been changed on idp.
+			_, inNetmakerGroup := user.UserGroups[models.UserGroupID(dbGroup.Name)]
+			_, inIDPGroup := groupMembersMap[user.ExternalIdentityProviderID]
+
+			if inNetmakerGroup && !inIDPGroup {
+				// use dbGroup.Name because the group name may have been changed on idp.
+				delete(dbUsersMap[user.ExternalIdentityProviderID].UserGroups, models.UserGroupID(dbGroup.Name))
+				modifiedUsers[user.ExternalIdentityProviderID] = struct{}{}
+			}
+
+			if !inNetmakerGroup && inIDPGroup {
+				// use dbGroup.Name because the group name may have been changed on idp.
+				dbUsersMap[user.ExternalIdentityProviderID].UserGroups[models.UserGroupID(dbGroup.Name)] = struct{}{}
+				modifiedUsers[user.ExternalIdentityProviderID] = struct{}{}
+			}
+		}
+	}
+
+	for userID := range modifiedUsers {
+		err = logic.UpsertUser(dbUsersMap[userID])
+		if err != nil {
+			return err
+		}
+	}
+
+	for _, group := range dbGroups {
+		if group.ExternalIdentityProviderID != "" {
+			if _, ok := idpGroupsMap[group.ExternalIdentityProviderID]; !ok {
+				// delete the group if it has been deleted on idp.
+				err = proLogic.DeleteUserGroup(group.ID)
+				if err != nil {
+					return err
+				}
+			}
+		}
+	}
+
+	return nil
+}

+ 92 - 5
pro/controllers/users.go

@@ -62,6 +62,9 @@ func UserHandlers(r *mux.Router) {
 	r.HandleFunc("/api/users/{username}/remote_access_gw/{remote_access_gateway_id}", logic.SecurityCheck(true, http.HandlerFunc(removeUserFromRemoteAccessGW))).Methods(http.MethodDelete)
 	r.HandleFunc("/api/users/{username}/remote_access_gw", logic.SecurityCheck(false, logic.ContinueIfUserMatch(http.HandlerFunc(getUserRemoteAccessGwsV1)))).Methods(http.MethodGet)
 	r.HandleFunc("/api/users/ingress/{ingress_id}", logic.SecurityCheck(true, http.HandlerFunc(ingressGatewayUsers))).Methods(http.MethodGet)
+
+	r.HandleFunc("/api/idp/sync", logic.SecurityCheck(true, http.HandlerFunc(syncIDP))).Methods(http.MethodPost)
+	r.HandleFunc("/api/idp", logic.SecurityCheck(true, http.HandlerFunc(removeIDPIntegration))).Methods(http.MethodDelete)
 }
 
 // swagger:route POST /api/v1/users/invite-signup user userInviteSignUp
@@ -546,6 +549,9 @@ func updateUserGroup(w http.ResponseWriter, r *http.Request) {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest"))
 		return
 	}
+
+	userGroup.ExternalIdentityProviderID = currUserG.ExternalIdentityProviderID
+
 	err = proLogic.UpdateUserGroup(userGroup)
 	if err != nil {
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
@@ -1423,7 +1429,7 @@ func getPendingUsers(w http.ResponseWriter, r *http.Request) {
 	// set header.
 	w.Header().Set("Content-Type", "application/json")
 
-	users, err := logic.ListPendingUsers()
+	users, err := logic.ListPendingReturnUsers()
 	if err != nil {
 		logger.Log(0, "failed to fetch users: ", err.Error())
 		logic.ReturnErrorResponse(w, r, logic.FormatError(err, "internal"))
@@ -1461,9 +1467,11 @@ func approvePendingUser(w http.ResponseWriter, r *http.Request) {
 				return
 			}
 			if err = logic.CreateUser(&models.User{
-				UserName:       user.UserName,
-				Password:       newPass,
-				PlatformRoleID: models.ServiceUser,
+				UserName:                   user.UserName,
+				ExternalIdentityProviderID: user.ExternalIdentityProviderID,
+				Password:                   newPass,
+				AuthType:                   user.AuthType,
+				PlatformRoleID:             models.ServiceUser,
 			}); err != nil {
 				logic.ReturnErrorResponse(w, r, logic.FormatError(fmt.Errorf("failed to create user: %s", err), "internal"))
 				return
@@ -1505,7 +1513,7 @@ func deletePendingUser(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "application/json")
 	var params = mux.Vars(r)
 	username := params["username"]
-	users, err := logic.ListPendingUsers()
+	users, err := logic.ListPendingReturnUsers()
 
 	if err != nil {
 		logger.Log(0, "failed to fetch users: ", err.Error())
@@ -1569,3 +1577,82 @@ func deleteAllPendingUsers(w http.ResponseWriter, r *http.Request) {
 	})
 	logic.ReturnSuccessResponse(w, r, "cleared all pending users")
 }
+
+// @Summary     Sync users and groups from idp.
+// @Router      /api/idp/sync [post]
+// @Tags        IDP
+// @Success     200 {object} models.SuccessResponse
+func syncIDP(w http.ResponseWriter, r *http.Request) {
+	go func() {
+		err := proAuth.SyncFromIDP()
+		if err != nil {
+			logger.Log(0, "failed to sync from idp: ", err.Error())
+		} else {
+			logger.Log(0, "sync from idp complete")
+		}
+	}()
+
+	logic.ReturnSuccessResponse(w, r, "starting sync from idp")
+}
+
+// @Summary     Remove idp integration.
+// @Router      /api/idp [delete]
+// @Tags        IDP
+// @Success     200 {object} models.SuccessResponse
+// @Failure     500 {object} models.ErrorResponse
+func removeIDPIntegration(w http.ResponseWriter, r *http.Request) {
+	superAdmin, err := logic.GetSuperAdmin()
+	if err != nil {
+		logic.ReturnErrorResponse(
+			w,
+			r,
+			logic.FormatError(fmt.Errorf("failed to get superadmin: %v", err), "internal"),
+		)
+		return
+	}
+
+	if superAdmin.AuthType == models.OAuth {
+		logic.ReturnErrorResponse(
+			w,
+			r,
+			logic.FormatError(fmt.Errorf("cannot remove idp integration with superadmin oauth user"), "badrequest"),
+		)
+		return
+	}
+
+	settings := logic.GetServerSettings()
+	settings.AuthProvider = ""
+	settings.OIDCIssuer = ""
+	settings.ClientID = ""
+	settings.ClientSecret = ""
+	settings.SyncEnabled = false
+	settings.GoogleAdminEmail = ""
+	settings.GoogleSACredsJson = ""
+	settings.AzureTenant = ""
+	settings.UserFilters = nil
+	settings.GroupFilters = nil
+
+	err = logic.UpsertServerSettings(settings)
+	if err != nil {
+		logic.ReturnErrorResponse(
+			w,
+			r,
+			logic.FormatError(fmt.Errorf("failed to remove idp integration: %v", err), "internal"),
+		)
+		return
+	}
+
+	proAuth.ResetAuthProvider()
+	proAuth.ResetIDPSyncHook()
+
+	go func() {
+		err := proAuth.SyncFromIDP()
+		if err != nil {
+			logger.Log(0, "failed to sync from idp: ", err.Error())
+		} else {
+			logger.Log(0, "sync from idp complete")
+		}
+	}()
+
+	logic.ReturnSuccessResponse(w, r, "removed idp integration successfully")
+}

+ 167 - 0
pro/idp/azure/azure.go

@@ -0,0 +1,167 @@
+package azure
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"github.com/gravitl/netmaker/logic"
+	"github.com/gravitl/netmaker/pro/idp"
+	"net/http"
+	"net/url"
+)
+
+type Client struct {
+	clientID     string
+	clientSecret string
+	tenantID     string
+}
+
+func NewAzureEntraIDClient() *Client {
+	settings := logic.GetServerSettings()
+
+	return &Client{
+		clientID:     settings.ClientID,
+		clientSecret: settings.ClientSecret,
+		tenantID:     settings.AzureTenant,
+	}
+}
+
+func (a *Client) GetUsers() ([]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
+	}
+
+	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 users getUsersResponse
+	err = json.NewDecoder(resp.Body).Decode(&users)
+	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,
+		}
+	}
+
+	return retval, nil
+}
+
+func (a *Client) GetGroups() ([]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
+	}
+
+	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 groups getGroupsResponse
+	err = json.NewDecoder(resp.Body).Decode(&groups)
+	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
+		}
+
+		retval[i] = idp.Group{
+			ID:      group.Id,
+			Name:    group.DisplayName,
+			Members: retvalMembers,
+		}
+	}
+
+	return retval, nil
+}
+
+func (a *Client) getAccessToken() (string, error) {
+	tokenURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", a.tenantID)
+
+	var data = url.Values{}
+	data.Set("grant_type", "client_credentials")
+	data.Set("client_id", a.clientID)
+	data.Set("client_secret", a.clientSecret)
+	data.Set("scope", "https://graph.microsoft.com/.default")
+
+	resp, err := http.PostForm(tokenURL, data)
+	if err != nil {
+		return "", err
+	}
+	defer func() {
+		_ = resp.Body.Close()
+	}()
+
+	var tokenResp map[string]interface{}
+	err = json.NewDecoder(resp.Body).Decode(&tokenResp)
+	if err != nil {
+		return "", err
+	}
+
+	if token, ok := tokenResp["access_token"].(string); ok {
+		return token, nil
+	}
+
+	return "", errors.New("failed to get access token")
+}
+
+type getUsersResponse struct {
+	OdataContext string `json:"@odata.context"`
+	Value        []struct {
+		Id                string `json:"id"`
+		UserPrincipalName string `json:"userPrincipalName"`
+		DisplayName       string `json:"displayName"`
+		AccountEnabled    bool   `json:"accountEnabled"`
+	} `json:"value"`
+}
+
+type getGroupsResponse struct {
+	OdataContext string `json:"@odata.context"`
+	Value        []struct {
+		Id          string `json:"id"`
+		DisplayName string `json:"displayName"`
+		Members     []struct {
+			OdataType string `json:"@odata.type"`
+			Id        string `json:"id"`
+		} `json:"members"`
+	} `json:"value"`
+}

+ 115 - 0
pro/idp/google/google.go

@@ -0,0 +1,115 @@
+package google
+
+import (
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"github.com/gravitl/netmaker/logic"
+	"github.com/gravitl/netmaker/pro/idp"
+	admindir "google.golang.org/api/admin/directory/v1"
+	"google.golang.org/api/impersonate"
+	"google.golang.org/api/option"
+)
+
+type Client struct {
+	service *admindir.Service
+}
+
+func NewGoogleWorkspaceClient() (*Client, error) {
+	settings := logic.GetServerSettings()
+
+	credsJson, err := base64.StdEncoding.DecodeString(settings.GoogleSACredsJson)
+	if err != nil {
+		return nil, err
+	}
+
+	credsJsonMap := make(map[string]interface{})
+	err = json.Unmarshal(credsJson, &credsJsonMap)
+	if err != nil {
+		return nil, err
+	}
+
+	source, err := impersonate.CredentialsTokenSource(
+		context.TODO(),
+		impersonate.CredentialsConfig{
+			TargetPrincipal: credsJsonMap["client_email"].(string),
+			Scopes: []string{
+				admindir.AdminDirectoryUserReadonlyScope,
+				admindir.AdminDirectoryGroupReadonlyScope,
+				admindir.AdminDirectoryGroupMemberReadonlyScope,
+			},
+			Subject: settings.GoogleAdminEmail,
+		},
+		option.WithCredentialsJSON(credsJson),
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	service, err := admindir.NewService(
+		context.TODO(),
+		option.WithTokenSource(source),
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Client{
+		service: service,
+	}, nil
+}
+
+func (g *Client) GetUsers() ([]idp.User, error) {
+	var retval []idp.User
+	err := g.service.Users.List().
+		Customer("my_customer").
+		Fields("users(id,primaryEmail,name,suspended)", "nextPageToken").
+		Pages(context.TODO(), func(users *admindir.Users) error {
+			for _, user := range users.Users {
+				retval = append(retval, idp.User{
+					ID:              user.Id,
+					Username:        user.PrimaryEmail,
+					DisplayName:     user.Name.FullName,
+					AccountDisabled: user.Suspended,
+				})
+			}
+
+			return nil
+		})
+
+	return retval, err
+}
+
+func (g *Client) GetGroups() ([]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 retvalMembers []string
+				err := g.service.Members.List(group.Id).
+					Fields("members(id)", "nextPageToken").
+					Pages(context.TODO(), func(members *admindir.Members) error {
+						for _, member := range members.Members {
+							retvalMembers = append(retvalMembers, member.Id)
+						}
+
+						return nil
+					})
+				if err != nil {
+					return err
+				}
+
+				retval = append(retval, idp.Group{
+					ID:      group.Id,
+					Name:    group.Name,
+					Members: retvalMembers,
+				})
+			}
+
+			return nil
+		})
+
+	return retval, err
+}

+ 19 - 0
pro/idp/idp.go

@@ -0,0 +1,19 @@
+package idp
+
+type Client interface {
+	GetUsers() ([]User, error)
+	GetGroups() ([]Group, error)
+}
+
+type User struct {
+	ID              string
+	Username        string
+	DisplayName     string
+	AccountDisabled bool
+}
+
+type Group struct {
+	ID      string
+	Name    string
+	Members []string
+}

+ 4 - 1
pro/initialize.go

@@ -93,6 +93,7 @@ func InitPro() {
 		}
 		proLogic.LoadNodeMetricsToCache()
 		proLogic.InitFailOverCache()
+		auth.StartSyncHook()
 		email.Init()
 		proLogic.EventWatcher()
 	})
@@ -135,12 +136,14 @@ func InitPro() {
 	logic.UpdateUserGwAccess = proLogic.UpdateUserGwAccess
 	logic.CreateDefaultUserPolicies = proLogic.CreateDefaultUserPolicies
 	logic.MigrateUserRoleAndGroups = proLogic.MigrateUserRoleAndGroups
+	logic.MigrateGroups = proLogic.MigrateGroups
 	logic.IntialiseGroups = proLogic.UserGroupsInit
 	logic.AddGlobalNetRolesToAdmins = proLogic.AddGlobalNetRolesToAdmins
 	logic.GetUserGroupsInNetwork = proLogic.GetUserGroupsInNetwork
 	logic.GetUserGroup = proLogic.GetUserGroup
 	logic.GetNodeStatus = proLogic.GetNodeStatus
-	logic.InitializeAuthProvider = auth.InitializeAuthProvider
+	logic.ResetAuthProvider = auth.ResetAuthProvider
+	logic.ResetIDPSyncHook = auth.ResetIDPSyncHook
 	logic.EmailInit = email.Init
 	logic.LogEvent = proLogic.LogEvent
 }

+ 73 - 17
pro/logic/migrate.go

@@ -1,14 +1,75 @@
 package logic
 
 import (
-	"fmt"
+	"encoding/json"
+	"github.com/google/uuid"
+	"github.com/gravitl/netmaker/database"
 
 	"github.com/gravitl/netmaker/logic"
 	"github.com/gravitl/netmaker/models"
 )
 
+func MigrateGroups() {
+	groups, err := ListUserGroups()
+	if err != nil {
+		return
+	}
+
+	groupMapping := make(map[models.UserGroupID]models.UserGroupID)
+
+	for _, group := range groups {
+		if group.Default {
+			continue
+		}
+
+		_, err := uuid.Parse(string(group.ID))
+		if err == nil {
+			// group id is already an uuid, so no need to update
+			continue
+		}
+
+		oldGroupID := group.ID
+		group.ID = models.UserGroupID(uuid.NewString())
+		groupMapping[oldGroupID] = group.ID
+
+		groupBytes, err := json.Marshal(group)
+		if err != nil {
+			continue
+		}
+
+		err = database.Insert(group.ID.String(), string(groupBytes), database.USER_GROUPS_TABLE_NAME)
+		if err != nil {
+			continue
+		}
+
+		err = database.DeleteRecord(database.USER_GROUPS_TABLE_NAME, oldGroupID.String())
+		if err != nil {
+			continue
+		}
+	}
+
+	users, err := logic.GetUsersDB()
+	if err != nil {
+		return
+	}
+
+	for _, user := range users {
+		userGroups := make(map[models.UserGroupID]struct{})
+		for groupID := range user.UserGroups {
+			newGroupID, ok := groupMapping[groupID]
+			if !ok {
+				userGroups[groupID] = struct{}{}
+			} else {
+				userGroups[newGroupID] = struct{}{}
+			}
+		}
+
+		user.UserGroups = userGroups
+		logic.UpsertUser(user)
+	}
+}
+
 func MigrateUserRoleAndGroups(user models.User) {
-	var err error
 	if user.PlatformRoleID == models.AdminRole || user.PlatformRoleID == models.SuperAdminRole {
 		return
 	}
@@ -20,22 +81,21 @@ func MigrateUserRoleAndGroups(user models.User) {
 			if err != nil {
 				continue
 			}
-			var g models.UserGroup
+			var groupID models.UserGroupID
 			if user.PlatformRoleID == models.ServiceUser {
-				g, err = GetUserGroup(models.UserGroupID(fmt.Sprintf("%s-%s-grp", gwNode.Network, models.NetworkUser)))
+				groupID = GetDefaultNetworkUserGroupID(models.NetworkID(gwNode.Network))
 			} else {
-				g, err = GetUserGroup(models.UserGroupID(fmt.Sprintf("%s-%s-grp",
-					gwNode.Network, models.NetworkAdmin)))
+				groupID = GetDefaultNetworkAdminGroupID(models.NetworkID(gwNode.Network))
 			}
 			if err != nil {
 				continue
 			}
-			user.UserGroups[g.ID] = struct{}{}
+			user.UserGroups[groupID] = struct{}{}
 		}
 	}
 	if len(user.NetworkRoles) > 0 {
 		for netID, netRoles := range user.NetworkRoles {
-			var g models.UserGroup
+			var groupID models.UserGroupID
 			adminAccess := false
 			for netRoleID := range netRoles {
 				permTemplate, err := logic.GetRole(netRoleID)
@@ -47,19 +107,15 @@ func MigrateUserRoleAndGroups(user models.User) {
 			}
 
 			if user.PlatformRoleID == models.ServiceUser {
-				g, err = GetUserGroup(models.UserGroupID(fmt.Sprintf("%s-%s-grp", netID, models.NetworkUser)))
+				groupID = GetDefaultNetworkUserGroupID(netID)
 			} else {
-				role := models.NetworkUser
 				if adminAccess {
-					role = models.NetworkAdmin
+					groupID = GetDefaultNetworkAdminGroupID(netID)
+				} else {
+					groupID = GetDefaultNetworkUserGroupID(netID)
 				}
-				g, err = GetUserGroup(models.UserGroupID(fmt.Sprintf("%s-%s-grp",
-					netID, role)))
-			}
-			if err != nil {
-				continue
 			}
-			user.UserGroups[g.ID] = struct{}{}
+			user.UserGroups[groupID] = struct{}{}
 			user.NetworkRoles = make(map[models.NetworkID]map[models.UserRoleID]struct{})
 		}
 

+ 63 - 26
pro/logic/user_mgmt.go

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"github.com/google/uuid"
 	"time"
 
 	"github.com/gravitl/netmaker/database"
@@ -14,6 +15,11 @@ import (
 	"golang.org/x/exp/slog"
 )
 
+var (
+	globalNetworksAdminGroupID = models.UserGroupID(fmt.Sprintf("global-%s-grp", models.NetworkAdmin))
+	globalNetworksUserGroupID  = models.UserGroupID(fmt.Sprintf("global-%s-grp", models.NetworkUser))
+)
+
 var ServiceUserPermissionTemplate = models.UserRolePermissionTemplate{
 	ID:                  models.ServiceUser,
 	Default:             true,
@@ -111,7 +117,7 @@ func UserRolesInit() {
 func UserGroupsInit() {
 	// create default network groups
 	var NetworkGlobalAdminGroup = models.UserGroup{
-		ID:       models.UserGroupID(fmt.Sprintf("global-%s-grp", models.NetworkAdmin)),
+		ID:       globalNetworksAdminGroupID,
 		Default:  true,
 		Name:     "All Networks Admin Group",
 		MetaData: "can manage configuration of all networks",
@@ -122,11 +128,11 @@ func UserGroupsInit() {
 		},
 	}
 	var NetworkGlobalUserGroup = models.UserGroup{
-		ID:      models.UserGroupID(fmt.Sprintf("global-%s-grp", models.NetworkUser)),
+		ID:      globalNetworksUserGroupID,
 		Name:    "All Networks User Group",
 		Default: true,
 		NetworkRoles: map[models.NetworkID]map[models.UserRoleID]struct{}{
-			models.NetworkID(models.AllNetworks): {
+			models.AllNetworks: {
 				models.UserRoleID(fmt.Sprintf("global-%s", models.NetworkUser)): {},
 			},
 		},
@@ -215,7 +221,7 @@ func CreateDefaultNetworkRolesAndGroups(netID models.NetworkID) {
 
 	// create default network groups
 	var NetworkAdminGroup = models.UserGroup{
-		ID:      models.UserGroupID(fmt.Sprintf("%s-%s-grp", netID, models.NetworkAdmin)),
+		ID:      GetDefaultNetworkAdminGroupID(netID),
 		Name:    fmt.Sprintf("%s Admin Group", netID),
 		Default: true,
 		NetworkRoles: map[models.NetworkID]map[models.UserRoleID]struct{}{
@@ -226,7 +232,7 @@ func CreateDefaultNetworkRolesAndGroups(netID models.NetworkID) {
 		MetaData: fmt.Sprintf("can manage your network `%s` configuration including adding and removing devices.", netID),
 	}
 	var NetworkUserGroup = models.UserGroup{
-		ID:      models.UserGroupID(fmt.Sprintf("%s-%s-grp", netID, models.NetworkUser)),
+		ID:      GetDefaultNetworkUserGroupID(netID),
 		Name:    fmt.Sprintf("%s User Group", netID),
 		Default: true,
 		NetworkRoles: map[models.NetworkID]map[models.UserRoleID]struct{}{
@@ -248,28 +254,29 @@ func DeleteNetworkRoles(netID string) {
 	if err != nil {
 		return
 	}
-	defaultUserGrp := fmt.Sprintf("%s-%s-grp", netID, models.NetworkUser)
-	defaultAdminGrp := fmt.Sprintf("%s-%s-grp", netID, models.NetworkAdmin)
+
+	defaultAdminGrpID := GetDefaultNetworkAdminGroupID(models.NetworkID(netID))
+	defaultUserGrpID := GetDefaultNetworkUserGroupID(models.NetworkID(netID))
 	for _, user := range users {
 		var upsert bool
 		if _, ok := user.NetworkRoles[models.NetworkID(netID)]; ok {
 			delete(user.NetworkRoles, models.NetworkID(netID))
 			upsert = true
 		}
-		if _, ok := user.UserGroups[models.UserGroupID(defaultUserGrp)]; ok {
-			delete(user.UserGroups, models.UserGroupID(defaultUserGrp))
+		if _, ok := user.UserGroups[defaultUserGrpID]; ok {
+			delete(user.UserGroups, defaultUserGrpID)
 			upsert = true
 		}
-		if _, ok := user.UserGroups[models.UserGroupID(defaultAdminGrp)]; ok {
-			delete(user.UserGroups, models.UserGroupID(defaultAdminGrp))
+		if _, ok := user.UserGroups[defaultAdminGrpID]; ok {
+			delete(user.UserGroups, defaultAdminGrpID)
 			upsert = true
 		}
 		if upsert {
 			logic.UpsertUser(user)
 		}
 	}
-	database.DeleteRecord(database.USER_GROUPS_TABLE_NAME, defaultUserGrp)
-	database.DeleteRecord(database.USER_GROUPS_TABLE_NAME, defaultAdminGrp)
+	database.DeleteRecord(database.USER_GROUPS_TABLE_NAME, defaultUserGrpID.String())
+	database.DeleteRecord(database.USER_GROUPS_TABLE_NAME, defaultAdminGrpID.String())
 	userGs, _ := ListUserGroups()
 	for _, userGI := range userGs {
 		if _, ok := userGI.NetworkRoles[models.NetworkID(netID)]; ok {
@@ -524,14 +531,31 @@ func ValidateUpdateGroupReq(g models.UserGroup) error {
 
 // CreateUserGroup - creates new user group
 func CreateUserGroup(g models.UserGroup) error {
-	// check if role already exists
-	if g.ID == "" {
-		return errors.New("group id cannot be empty")
+	// default groups are currently created directly in the db.
+	// this check is only to prevent future errors.
+	if g.Default && g.ID == "" {
+		return errors.New("group id cannot be empty for default group")
 	}
-	_, err := database.FetchRecord(database.USER_GROUPS_TABLE_NAME, g.ID.String())
-	if err == nil {
-		return errors.New("group already exists")
+
+	if !g.Default {
+		g.ID = models.UserGroupID(uuid.NewString())
+	}
+
+	// check if the group already exists
+	if g.Name == "" {
+		return errors.New("group name cannot be empty")
+	}
+	groups, err := ListUserGroups()
+	if err != nil {
+		return err
+	}
+
+	for _, group := range groups {
+		if group.Name == g.Name {
+			return errors.New("group already exists")
+		}
 	}
+
 	d, err := json.Marshal(g)
 	if err != nil {
 		return err
@@ -553,6 +577,14 @@ func GetUserGroup(gid models.UserGroupID) (models.UserGroup, error) {
 	return ug, nil
 }
 
+func GetDefaultNetworkAdminGroupID(networkID models.NetworkID) models.UserGroupID {
+	return models.UserGroupID(fmt.Sprintf("%s-%s-grp", networkID, models.NetworkAdmin))
+}
+
+func GetDefaultNetworkUserGroupID(networkID models.NetworkID) models.UserGroupID {
+	return models.UserGroupID(fmt.Sprintf("%s-%s-grp", networkID, models.NetworkUser))
+}
+
 // ListUserGroups - lists user groups
 func ListUserGroups() ([]models.UserGroup, error) {
 	data, err := database.FetchRecords(database.USER_GROUPS_TABLE_NAME)
@@ -573,7 +605,7 @@ func ListUserGroups() ([]models.UserGroup, error) {
 
 // UpdateUserGroup - updates new user group
 func UpdateUserGroup(g models.UserGroup) error {
-	// check if group exists
+	// check if the group exists
 	if g.ID == "" {
 		return errors.New("group id cannot be empty")
 	}
@@ -591,7 +623,7 @@ func UpdateUserGroup(g models.UserGroup) error {
 // DeleteUserGroup - deletes user group
 func DeleteUserGroup(gid models.UserGroupID) error {
 	users, err := logic.GetUsersDB()
-	if err != nil {
+	if err != nil && !database.IsEmptyRecord(err) {
 		return err
 	}
 	for _, user := range users {
@@ -1110,6 +1142,8 @@ func CreateDefaultUserPolicies(netID models.NetworkID) {
 	}
 
 	if !logic.IsAclExists(fmt.Sprintf("%s.%s-grp", netID, models.NetworkAdmin)) {
+		networkAdminGroupID := GetDefaultNetworkAdminGroupID(netID)
+
 		defaultUserAcl := models.Acl{
 			ID:          fmt.Sprintf("%s.%s-grp", netID, models.NetworkAdmin),
 			Name:        "Network Admin",
@@ -1122,11 +1156,11 @@ func CreateDefaultUserPolicies(netID models.NetworkID) {
 			Src: []models.AclPolicyTag{
 				{
 					ID:    models.UserGroupAclID,
-					Value: fmt.Sprintf("%s-%s-grp", netID, models.NetworkAdmin),
+					Value: globalNetworksAdminGroupID.String(),
 				},
 				{
 					ID:    models.UserGroupAclID,
-					Value: fmt.Sprintf("global-%s-grp", models.NetworkAdmin),
+					Value: networkAdminGroupID.String(),
 				},
 			},
 			Dst: []models.AclPolicyTag{
@@ -1143,6 +1177,8 @@ func CreateDefaultUserPolicies(netID models.NetworkID) {
 	}
 
 	if !logic.IsAclExists(fmt.Sprintf("%s.%s-grp", netID, models.NetworkUser)) {
+		networkUserGroupID := GetDefaultNetworkUserGroupID(netID)
+
 		defaultUserAcl := models.Acl{
 			ID:          fmt.Sprintf("%s.%s-grp", netID, models.NetworkUser),
 			Name:        "Network User",
@@ -1155,11 +1191,11 @@ func CreateDefaultUserPolicies(netID models.NetworkID) {
 			Src: []models.AclPolicyTag{
 				{
 					ID:    models.UserGroupAclID,
-					Value: fmt.Sprintf("%s-%s-grp", netID, models.NetworkUser),
+					Value: globalNetworksAdminGroupID.String(),
 				},
 				{
 					ID:    models.UserGroupAclID,
-					Value: fmt.Sprintf("global-%s-grp", models.NetworkUser),
+					Value: networkUserGroupID.String(),
 				},
 			},
 
@@ -1198,5 +1234,6 @@ func AddGlobalNetRolesToAdmins(u *models.User) {
 		return
 	}
 	u.UserGroups = make(map[models.UserGroupID]struct{})
-	u.UserGroups[models.UserGroupID(fmt.Sprintf("global-%s-grp", models.NetworkAdmin))] = struct{}{}
+
+	u.UserGroups[globalNetworksAdminGroupID] = struct{}{}
 }