Merge pull request #3557 from gravitl/NM-24

NM-24: IDP UX improvements
This commit is contained in:
Abhishek K 2025-07-29 19:54:48 +05:30 committed by GitHub
commit f075eebedb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 240 additions and 17 deletions

View file

@ -406,3 +406,20 @@ 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"`
}
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"`
}

View file

@ -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,17 +68,22 @@ func SyncFromIDP() error {
var idpGroups []idp.Group
var err error
defer func() {
idpSyncErr = err
}()
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 != "" {
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(),
}
}
}
}

View file

@ -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,8 @@ 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)
}
@ -1618,6 +1623,54 @@ 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
// @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

View file

@ -16,14 +16,80 @@ 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) 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) {
@ -55,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{
@ -97,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))
@ -141,10 +215,11 @@ 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 {
Error errorResponse `json:"error"`
OdataContext string `json:"@odata.context"`
Value []struct {
Id string `json:"id"`
@ -155,6 +230,7 @@ type getUsersResponse struct {
}
type getGroupsResponse struct {
Error errorResponse `json:"error"`
OdataContext string `json:"@odata.context"`
Value []struct {
Id string `json:"id"`
@ -165,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"`
}

View file

@ -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"
)
@ -15,10 +17,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 +38,7 @@ func NewGoogleWorkspaceClient() (*Client, error) {
admindir.AdminDirectoryGroupReadonlyScope,
admindir.AdminDirectoryGroupMemberReadonlyScope,
},
Subject: settings.GoogleAdminEmail,
Subject: adminEmail,
},
option.WithCredentialsJSON(credsJson),
)
@ -59,6 +59,42 @@ func NewGoogleWorkspaceClient() (*Client, error) {
}, nil
}
func NewGoogleWorkspaceClientFromSettings() (*Client, error) {
settings := logic.GetServerSettings()
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().

View file

@ -1,6 +1,7 @@
package idp
type Client interface {
Verify() error
GetUsers() ([]User, error)
GetGroups() ([]Group, error)
}