From 614cf77b5a0d5d766b2af1a6ec9ff774579b7139 Mon Sep 17 00:00:00 2001 From: Vishal Dalwadi <51291657+VishalDalwadi@users.noreply.github.com> Date: Wed, 21 May 2025 01:18:15 -0700 Subject: [PATCH] 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 Co-authored-by: Abhishek K Co-authored-by: the_aceix --- controllers/server.go | 3 +- controllers/user.go | 68 ++++++++ database/database.go | 2 - go.mod | 27 +++- go.sum | 68 ++++++-- logic/auth.go | 52 ++++++- logic/jwts.go | 2 + logic/security.go | 14 ++ logic/settings.go | 20 +++ logic/user_mgmt.go | 2 + logic/users.go | 36 +++-- migrate/migrate.go | 5 + models/settings.go | 6 + models/user_mgmt.go | 34 ++-- pro/auth/auth.go | 12 ++ pro/auth/azure-ad.go | 12 +- pro/auth/error.go | 8 + pro/auth/github.go | 12 +- pro/auth/google.go | 9 +- pro/auth/headless_callback.go | 4 +- pro/auth/oidc.go | 14 +- pro/auth/sync.go | 281 ++++++++++++++++++++++++++++++++++ pro/controllers/users.go | 97 +++++++++++- pro/idp/azure/azure.go | 167 ++++++++++++++++++++ pro/idp/google/google.go | 115 ++++++++++++++ pro/idp/idp.go | 19 +++ pro/initialize.go | 5 +- pro/logic/migrate.go | 90 +++++++++-- pro/logic/user_mgmt.go | 89 +++++++---- 29 files changed, 1168 insertions(+), 105 deletions(-) create mode 100644 pro/auth/sync.go create mode 100644 pro/idp/azure/azure.go create mode 100644 pro/idp/google/google.go create mode 100644 pro/idp/idp.go diff --git a/controllers/server.go b/controllers/server.go index bdcf51bd..6f5ee765 100644 --- a/controllers/server.go +++ b/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 { diff --git a/controllers/user.go b/controllers/user.go index 386095b2..f71f9064 100644 --- a/controllers/user.go +++ b/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. diff --git a/database/database.go b/database/database.go index 483eb35f..59abd7dd 100644 --- a/database/database.go +++ b/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 diff --git a/go.mod b/go.mod index 3c1b664c..82bd5006 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index f7823c29..313acd7b 100644 --- a/go.sum +++ b/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= diff --git a/logic/auth.go b/logic/auth.go index b2fa7d0d..cb427484 100644 --- a/logic/auth.go +++ b/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) diff --git a/logic/jwts.go b/logic/jwts.go index 64eea672..3872568c 100644 --- a/logic/jwts.go +++ b/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 }) diff --git a/logic/security.go b/logic/security.go index f4c8a23e..843ffa27 100644 --- a/logic/security.go +++ b/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") diff --git a/logic/settings.go b/logic/settings.go index 4b062743..d704b6e5 100644 --- a/logic/settings.go +++ b/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 diff --git a/logic/user_mgmt.go b/logic/user_mgmt.go index 7eb3de7b..c85a6b5b 100644 --- a/logic/user_mgmt.go +++ b/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 } diff --git a/logic/users.go b/logic/users.go index 168fd928..4b9b5171 100644 --- a/logic/users.go +++ b/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) diff --git a/migrate/migrate.go b/migrate/migrate.go index 484aa879..83c240f2 100644 --- a/migrate/migrate.go +++ b/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 { diff --git a/models/settings.go b/models/settings.go index c7aa394c..7ae0fb45 100644 --- a/models/settings.go +++ b/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"` diff --git a/models/user_mgmt.go b/models/user_mgmt.go index 17d6689e..94fa9595 100644 --- a/models/user_mgmt.go +++ b/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 diff --git a/pro/auth/auth.go b/pro/auth/auth.go index 215a6263..70c9de13 100644 --- a/pro/auth/auth.go +++ b/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() diff --git a/pro/auth/azure-ad.go b/pro/auth/azure-ad.go index e67edc3e..f6ce5d63 100644 --- a/pro/auth/azure-ad.go +++ b/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) diff --git a/pro/auth/error.go b/pro/auth/error.go index d9beecb6..a5863753 100644 --- a/pro/auth/error.go +++ b/pro/auth/error.go @@ -113,6 +113,8 @@ var notallowedtosignup = fmt.Sprintf(htmlBaseTemplate, `

Your email is not al var authTypeMismatch = fmt.Sprintf(htmlBaseTemplate, `

It looks like you already have an account with us using Basic Authentication.

To continue, please log in with your existing credentials or reset your password if needed.

`) +var userAccountDisabled = fmt.Sprintf(htmlBaseTemplate, `

Your account has been disabled. Please contact your administrator for more information about your account.

`) + 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)) +} diff --git a/pro/auth/github.go b/pro/auth/github.go index 0d543f48..a7d468d5 100644 --- a/pro/auth/github.go +++ b/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) diff --git a/pro/auth/google.go b/pro/auth/google.go index 97bf3143..767645f9 100644 --- a/pro/auth/google.go +++ b/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) diff --git a/pro/auth/headless_callback.go b/pro/auth/headless_callback.go index de0627ca..2e13ddc9 100644 --- a/pro/auth/headless_callback.go +++ b/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) diff --git a/pro/auth/oidc.go b/pro/auth/oidc.go index 37f8918f..30fdd08f 100644 --- a/pro/auth/oidc.go +++ b/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 } diff --git a/pro/auth/sync.go b/pro/auth/sync.go new file mode 100644 index 00000000..bd1f2ec0 --- /dev/null +++ b/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 +} diff --git a/pro/controllers/users.go b/pro/controllers/users.go index 94aade4c..272305d2 100644 --- a/pro/controllers/users.go +++ b/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") +} diff --git a/pro/idp/azure/azure.go b/pro/idp/azure/azure.go new file mode 100644 index 00000000..57fc736d --- /dev/null +++ b/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"` +} diff --git a/pro/idp/google/google.go b/pro/idp/google/google.go new file mode 100644 index 00000000..12f961c2 --- /dev/null +++ b/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 +} diff --git a/pro/idp/idp.go b/pro/idp/idp.go new file mode 100644 index 00000000..a76b65ff --- /dev/null +++ b/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 +} diff --git a/pro/initialize.go b/pro/initialize.go index 67705a3f..3b3dc942 100644 --- a/pro/initialize.go +++ b/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 } diff --git a/pro/logic/migrate.go b/pro/logic/migrate.go index fedef3c9..5fac1ead 100644 --- a/pro/logic/migrate.go +++ b/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{}) } diff --git a/pro/logic/user_mgmt.go b/pro/logic/user_mgmt.go index 0be37520..9f530968 100644 --- a/pro/logic/user_mgmt.go +++ b/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{}{} }