From e92f6ea8e4cd5b2c3d63f5436469361bca43ed82 Mon Sep 17 00:00:00 2001 From: Vishal Dalwadi Date: Thu, 17 Jul 2025 14:19:31 +0530 Subject: [PATCH 1/3] feat(go): add api to get idp sync status; --- models/structs.go | 8 ++++++++ pro/auth/sync.go | 34 ++++++++++++++++++++++++++++++++-- pro/controllers/users.go | 9 +++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/models/structs.go b/models/structs.go index 8a03746b..afdfaf18 100644 --- a/models/structs.go +++ b/models/structs.go @@ -399,3 +399,11 @@ type RsrcURLInfo struct { Method string Path string } + +type IDPSyncStatus struct { + // Status would be one of: in_progress, completed or failed. + Status string `json:"status"` + // Description is empty if the sync is ongoing or completed, + // and describes the error when the sync fails. + Description string `json:"description"` +} diff --git a/pro/auth/sync.go b/pro/auth/sync.go index 3c4e44a9..d0091df8 100644 --- a/pro/auth/sync.go +++ b/pro/auth/sync.go @@ -19,6 +19,8 @@ import ( var ( cancelSyncHook context.CancelFunc hookStopWg sync.WaitGroup + idpSyncMtx sync.Mutex + idpSyncErr error ) func ResetIDPSyncHook() { @@ -57,6 +59,8 @@ func runIDPSyncHook(ctx context.Context) { } func SyncFromIDP() error { + idpSyncMtx.Lock() + defer idpSyncMtx.Unlock() settings := logic.GetServerSettings() var idpClient idp.Client @@ -64,6 +68,10 @@ func SyncFromIDP() error { var idpGroups []idp.Group var err error + defer func() { + idpSyncErr = err + }() + switch settings.AuthProvider { case "google": idpClient, err = google.NewGoogleWorkspaceClient() @@ -74,7 +82,8 @@ func SyncFromIDP() error { idpClient = azure.NewAzureEntraIDClient() default: if settings.AuthProvider != "" { - return fmt.Errorf("invalid auth provider: %s", settings.AuthProvider) + err = fmt.Errorf("invalid auth provider: %s", settings.AuthProvider) + return err } } @@ -95,7 +104,8 @@ func SyncFromIDP() error { return err } - return syncGroups(idpGroups) + err = syncGroups(idpGroups) + return err } func syncUsers(idpUsers []idp.User) error { @@ -310,3 +320,23 @@ func syncGroups(idpGroups []idp.Group) error { return nil } + +func GetIDPSyncStatus() models.IDPSyncStatus { + if idpSyncMtx.TryLock() { + defer idpSyncMtx.Unlock() + return models.IDPSyncStatus{ + Status: "in_progress", + } + } else { + if idpSyncErr == nil { + return models.IDPSyncStatus{ + Status: "completed", + } + } else { + return models.IDPSyncStatus{ + Status: "failed", + Description: idpSyncErr.Error(), + } + } + } +} diff --git a/pro/controllers/users.go b/pro/controllers/users.go index c18396cf..4d7b41ea 100644 --- a/pro/controllers/users.go +++ b/pro/controllers/users.go @@ -64,6 +64,7 @@ func UserHandlers(r *mux.Router) { 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/sync/status", logic.SecurityCheck(true, http.HandlerFunc(getIDPSyncStatus))).Methods(http.MethodGet) r.HandleFunc("/api/idp", logic.SecurityCheck(true, http.HandlerFunc(removeIDPIntegration))).Methods(http.MethodDelete) } @@ -1618,6 +1619,14 @@ func syncIDP(w http.ResponseWriter, r *http.Request) { logic.ReturnSuccessResponse(w, r, "starting sync from idp") } +// @Summary Gets idp sync status. +// @Router /api/idp/sync/status [get] +// @Tags IDP +// @Success 200 {object} models.SuccessResponse +func getIDPSyncStatus(w http.ResponseWriter, r *http.Request) { + logic.ReturnSuccessResponseWithJson(w, r, proAuth.GetIDPSyncStatus(), "idp sync status retrieved") +} + // @Summary Remove idp integration. // @Router /api/idp [delete] // @Tags IDP From bdc9e2abd529eca4bb8b2643df5bbdc5dbee7354 Mon Sep 17 00:00:00 2001 From: Vishal Dalwadi Date: Thu, 17 Jul 2025 22:26:44 +0530 Subject: [PATCH 2/3] feat(go): rename client methods; --- pro/auth/sync.go | 4 ++-- pro/idp/azure/azure.go | 16 ++++++++++------ pro/idp/google/google.go | 14 +++++++++----- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/pro/auth/sync.go b/pro/auth/sync.go index d0091df8..101e6dbf 100644 --- a/pro/auth/sync.go +++ b/pro/auth/sync.go @@ -74,12 +74,12 @@ func SyncFromIDP() error { switch settings.AuthProvider { case "google": - idpClient, err = google.NewGoogleWorkspaceClient() + idpClient, err = google.NewGoogleWorkspaceClientFromSettings() if err != nil { return err } case "azure-ad": - idpClient = azure.NewAzureEntraIDClient() + idpClient = azure.NewAzureEntraIDClientFromSettings() default: if settings.AuthProvider != "" { err = fmt.Errorf("invalid auth provider: %s", settings.AuthProvider) diff --git a/pro/idp/azure/azure.go b/pro/idp/azure/azure.go index 57fc736d..2733e178 100644 --- a/pro/idp/azure/azure.go +++ b/pro/idp/azure/azure.go @@ -16,14 +16,18 @@ type Client struct { tenantID string } -func NewAzureEntraIDClient() *Client { +func NewAzureEntraIDClient(clientID, clientSecret, tenantID string) *Client { + return &Client{ + clientID: clientID, + clientSecret: clientSecret, + tenantID: tenantID, + } +} + +func NewAzureEntraIDClientFromSettings() *Client { settings := logic.GetServerSettings() - return &Client{ - clientID: settings.ClientID, - clientSecret: settings.ClientSecret, - tenantID: settings.AzureTenant, - } + return NewAzureEntraIDClient(settings.ClientID, settings.ClientSecret, settings.AzureTenant) } func (a *Client) GetUsers() ([]idp.User, error) { diff --git a/pro/idp/google/google.go b/pro/idp/google/google.go index f1039ef2..02959ecb 100644 --- a/pro/idp/google/google.go +++ b/pro/idp/google/google.go @@ -15,10 +15,8 @@ type Client struct { service *admindir.Service } -func NewGoogleWorkspaceClient() (*Client, error) { - settings := logic.GetServerSettings() - - credsJson, err := base64.StdEncoding.DecodeString(settings.GoogleSACredsJson) +func NewGoogleWorkspaceClient(adminEmail, creds string) (*Client, error) { + credsJson, err := base64.StdEncoding.DecodeString(creds) if err != nil { return nil, err } @@ -38,7 +36,7 @@ func NewGoogleWorkspaceClient() (*Client, error) { admindir.AdminDirectoryGroupReadonlyScope, admindir.AdminDirectoryGroupMemberReadonlyScope, }, - Subject: settings.GoogleAdminEmail, + Subject: adminEmail, }, option.WithCredentialsJSON(credsJson), ) @@ -59,6 +57,12 @@ func NewGoogleWorkspaceClient() (*Client, error) { }, nil } +func NewGoogleWorkspaceClientFromSettings() (*Client, error) { + settings := logic.GetServerSettings() + + return NewGoogleWorkspaceClient(settings.GoogleAdminEmail, settings.GoogleSACredsJson) +} + func (g *Client) GetUsers() ([]idp.User, error) { var retval []idp.User err := g.service.Users.List(). From 41591d1ef548501954fe1da72b70d379b89d2602 Mon Sep 17 00:00:00 2001 From: Vishal Dalwadi Date: Sun, 20 Jul 2025 15:28:23 +0530 Subject: [PATCH 3/3] feat(go): add idp sync test api; --- models/structs.go | 9 ++++ pro/controllers/users.go | 44 ++++++++++++++++++++ pro/idp/azure/azure.go | 88 ++++++++++++++++++++++++++++++++++++++-- pro/idp/google/google.go | 32 +++++++++++++++ pro/idp/idp.go | 1 + 5 files changed, 171 insertions(+), 3 deletions(-) diff --git a/models/structs.go b/models/structs.go index afdfaf18..8828c6f1 100644 --- a/models/structs.go +++ b/models/structs.go @@ -407,3 +407,12 @@ type IDPSyncStatus struct { // and describes the error when the sync fails. Description string `json:"description"` } + +type IDPSyncTestRequest struct { + AuthProvider string `json:"auth_provider"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AzureTenantID string `json:"azure_tenant_id"` + GoogleAdminEmail string `json:"google_admin_email"` + GoogleSACredsJson string `json:"google_sa_creds_json"` +} diff --git a/pro/controllers/users.go b/pro/controllers/users.go index 4d7b41ea..36ddde73 100644 --- a/pro/controllers/users.go +++ b/pro/controllers/users.go @@ -5,6 +5,9 @@ import ( "encoding/json" "errors" "fmt" + "github.com/gravitl/netmaker/pro/idp" + "github.com/gravitl/netmaker/pro/idp/azure" + "github.com/gravitl/netmaker/pro/idp/google" "net/http" "net/url" "strings" @@ -64,6 +67,7 @@ func UserHandlers(r *mux.Router) { 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/sync/test", logic.SecurityCheck(true, http.HandlerFunc(testIDPSync))).Methods(http.MethodPost) r.HandleFunc("/api/idp/sync/status", logic.SecurityCheck(true, http.HandlerFunc(getIDPSyncStatus))).Methods(http.MethodGet) r.HandleFunc("/api/idp", logic.SecurityCheck(true, http.HandlerFunc(removeIDPIntegration))).Methods(http.MethodDelete) } @@ -1619,6 +1623,46 @@ func syncIDP(w http.ResponseWriter, r *http.Request) { logic.ReturnSuccessResponse(w, r, "starting sync from idp") } +// @Summary Test IDP Sync Credentials. +// @Router /api/idp/sync/test [post] +// @Tags IDP +// @Success 200 {object} models.SuccessResponse +// @Failure 400 {object} models.ErrorResponse +func testIDPSync(w http.ResponseWriter, r *http.Request) { + var req models.IDPSyncTestRequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + err = fmt.Errorf("failed to decode request body: %v", err) + logger.Log(0, err.Error()) + logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest")) + return + } + + var idpClient idp.Client + switch req.AuthProvider { + case "google": + idpClient, err = google.NewGoogleWorkspaceClient(req.GoogleAdminEmail, req.GoogleSACredsJson) + if err != nil { + logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest")) + return + } + case "azure-ad": + idpClient = azure.NewAzureEntraIDClient(req.ClientID, req.ClientSecret, req.AzureTenantID) + default: + err = fmt.Errorf("invalid auth provider: %s", req.AuthProvider) + logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest")) + return + } + + err = idpClient.Verify() + if err != nil { + logic.ReturnErrorResponse(w, r, logic.FormatError(err, "badrequest")) + return + } + + logic.ReturnSuccessResponse(w, r, "idp sync test successful") +} + // @Summary Gets idp sync status. // @Router /api/idp/sync/status [get] // @Tags IDP diff --git a/pro/idp/azure/azure.go b/pro/idp/azure/azure.go index 2733e178..6987f3ea 100644 --- a/pro/idp/azure/azure.go +++ b/pro/idp/azure/azure.go @@ -30,6 +30,68 @@ func NewAzureEntraIDClientFromSettings() *Client { return NewAzureEntraIDClient(settings.ClientID, settings.ClientSecret, settings.AzureTenant) } +func (a *Client) Verify() error { + accessToken, err := a.getAccessToken() + if err != nil { + return err + } + + client := &http.Client{} + req, err := http.NewRequest("GET", "https://graph.microsoft.com/v1.0/users?$select=id,userPrincipalName,displayName,accountEnabled&$top=1", nil) + if err != nil { + return err + } + + req.Header.Add("Authorization", "Bearer "+accessToken) + req.Header.Add("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { + _ = resp.Body.Close() + }() + + var users getUsersResponse + err = json.NewDecoder(resp.Body).Decode(&users) + if err != nil { + return err + } + + if users.Error.Code != "" { + return errors.New(users.Error.Message) + } + + req, err = http.NewRequest("GET", "https://graph.microsoft.com/v1.0/groups?$select=id,displayName&$expand=members($select=id)&$top=1", nil) + if err != nil { + return err + } + + req.Header.Add("Authorization", "Bearer "+accessToken) + req.Header.Add("Accept", "application/json") + + resp, err = client.Do(req) + if err != nil { + return err + } + defer func() { + _ = resp.Body.Close() + }() + + var groups getGroupsResponse + err = json.NewDecoder(resp.Body).Decode(&groups) + if err != nil { + return err + } + + if groups.Error.Code != "" { + return errors.New(groups.Error.Message) + } + + return nil +} + func (a *Client) GetUsers() ([]idp.User, error) { accessToken, err := a.getAccessToken() if err != nil { @@ -59,6 +121,10 @@ func (a *Client) GetUsers() ([]idp.User, error) { return nil, err } + if users.Error.Code != "" { + return nil, errors.New(users.Error.Message) + } + retval := make([]idp.User, len(users.Value)) for i, user := range users.Value { retval[i] = idp.User{ @@ -101,6 +167,10 @@ func (a *Client) GetGroups() ([]idp.Group, error) { return nil, err } + if groups.Error.Code != "" { + return nil, errors.New(groups.Error.Message) + } + retval := make([]idp.Group, len(groups.Value)) for i, group := range groups.Value { retvalMembers := make([]string, len(group.Members)) @@ -145,11 +215,12 @@ func (a *Client) getAccessToken() (string, error) { return token, nil } - return "", errors.New("failed to get access token") + return "", errors.New("invalid credentials") } type getUsersResponse struct { - OdataContext string `json:"@odata.context"` + Error errorResponse `json:"error"` + OdataContext string `json:"@odata.context"` Value []struct { Id string `json:"id"` UserPrincipalName string `json:"userPrincipalName"` @@ -159,7 +230,8 @@ type getUsersResponse struct { } type getGroupsResponse struct { - OdataContext string `json:"@odata.context"` + Error errorResponse `json:"error"` + OdataContext string `json:"@odata.context"` Value []struct { Id string `json:"id"` DisplayName string `json:"displayName"` @@ -169,3 +241,13 @@ type getGroupsResponse struct { } `json:"members"` } `json:"value"` } + +type errorResponse struct { + Code string `json:"code"` + Message string `json:"message"` + InnerError struct { + Date string `json:"date"` + RequestId string `json:"request-id"` + ClientRequestId string `json:"client-request-id"` + } `json:"innerError"` +} diff --git a/pro/idp/google/google.go b/pro/idp/google/google.go index 02959ecb..fc2246a0 100644 --- a/pro/idp/google/google.go +++ b/pro/idp/google/google.go @@ -4,9 +4,11 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "github.com/gravitl/netmaker/logic" "github.com/gravitl/netmaker/pro/idp" admindir "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/googleapi" "google.golang.org/api/impersonate" "google.golang.org/api/option" ) @@ -63,6 +65,36 @@ func NewGoogleWorkspaceClientFromSettings() (*Client, error) { return NewGoogleWorkspaceClient(settings.GoogleAdminEmail, settings.GoogleSACredsJson) } +func (g *Client) Verify() error { + _, err := g.service.Users.List(). + Customer("my_customer"). + MaxResults(1). + Do() + if err != nil { + var gerr *googleapi.Error + if errors.As(err, &gerr) { + return errors.New(gerr.Message) + } + + return err + } + + _, err = g.service.Groups.List(). + Customer("my_customer"). + MaxResults(1). + Do() + if err != nil { + var gerr *googleapi.Error + if errors.As(err, &gerr) { + return errors.New(gerr.Message) + } + + return err + } + + return nil +} + func (g *Client) GetUsers() ([]idp.User, error) { var retval []idp.User err := g.service.Users.List(). diff --git a/pro/idp/idp.go b/pro/idp/idp.go index 7e190c73..67bed93b 100644 --- a/pro/idp/idp.go +++ b/pro/idp/idp.go @@ -1,6 +1,7 @@ package idp type Client interface { + Verify() error GetUsers() ([]User, error) GetGroups() ([]Group, error) }