From 0b329f7881d093fb399abc61b05bb252df7f9834 Mon Sep 17 00:00:00 2001 From: Bethuel Mmbaga Date: Tue, 24 Mar 2026 13:21:42 +0300 Subject: [PATCH] [management] Replace JumpCloud SDK with direct HTTP calls (#5591) --- go.mod | 1 - go.sum | 2 - management/server/idp/idp.go | 1 + management/server/idp/jumpcloud.go | 198 ++++++++++++++-------- management/server/idp/jumpcloud_test.go | 216 ++++++++++++++++++++++++ 5 files changed, 343 insertions(+), 75 deletions(-) diff --git a/go.mod b/go.mod index 2c911aa4f..3661d6fe0 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,6 @@ require ( require ( fyne.io/fyne/v2 v2.7.0 fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9 - github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible github.com/awnumar/memguard v0.23.0 github.com/aws/aws-sdk-go-v2 v1.36.3 github.com/aws/aws-sdk-go-v2/config v1.29.14 diff --git a/go.sum b/go.sum index e26b4edaf..57e07dd7b 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,6 @@ github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSC github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= -github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible h1:hqcTK6ZISdip65SR792lwYJTa/axESA0889D3UlZbLo= -github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible/go.mod h1:6B1nuc1MUs6c62ODZDl7hVE5Pv7O2XGSkgg2olnq34I= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= diff --git a/management/server/idp/idp.go b/management/server/idp/idp.go index 28e3d81f9..20d6cacd5 100644 --- a/management/server/idp/idp.go +++ b/management/server/idp/idp.go @@ -197,6 +197,7 @@ func NewManager(ctx context.Context, config Config, appMetrics telemetry.AppMetr case "jumpcloud": return NewJumpCloudManager(JumpCloudClientConfig{ APIToken: config.ExtraConfig["ApiToken"], + ApiUrl: config.ExtraConfig["ApiUrl"], }, appMetrics) case "pocketid": return NewPocketIdManager(PocketIdClientConfig{ diff --git a/management/server/idp/jumpcloud.go b/management/server/idp/jumpcloud.go index 8c4a9d089..f0dec3a9b 100644 --- a/management/server/idp/jumpcloud.go +++ b/management/server/idp/jumpcloud.go @@ -1,24 +1,40 @@ package idp import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "net/http" "strings" - v1 "github.com/TheJumpCloud/jcapi-go/v1" - "github.com/netbirdio/netbird/management/server/telemetry" ) const ( - contentType = "application/json" - accept = "application/json" + jumpCloudDefaultApiUrl = "https://console.jumpcloud.com" + jumpCloudSearchPageSize = 100 ) +// jumpCloudUser represents a JumpCloud V1 API system user. +type jumpCloudUser struct { + ID string `json:"_id"` + Email string `json:"email"` + Firstname string `json:"firstname"` + Middlename string `json:"middlename"` + Lastname string `json:"lastname"` +} + +// jumpCloudUserList represents the response from the JumpCloud search endpoint. +type jumpCloudUserList struct { + Results []jumpCloudUser `json:"results"` + TotalCount int `json:"totalCount"` +} + // JumpCloudManager JumpCloud manager client instance. type JumpCloudManager struct { - client *v1.APIClient + apiBase string apiToken string httpClient ManagerHTTPClient credentials ManagerCredentials @@ -29,6 +45,7 @@ type JumpCloudManager struct { // JumpCloudClientConfig JumpCloud manager client configurations. type JumpCloudClientConfig struct { APIToken string + ApiUrl string } // JumpCloudCredentials JumpCloud authentication information. @@ -55,7 +72,15 @@ func NewJumpCloudManager(config JumpCloudClientConfig, appMetrics telemetry.AppM return nil, fmt.Errorf("jumpCloud IdP configuration is incomplete, ApiToken is missing") } - client := v1.NewAPIClient(v1.NewConfiguration()) + apiBase := config.ApiUrl + if apiBase == "" { + apiBase = jumpCloudDefaultApiUrl + } + apiBase = strings.TrimSuffix(apiBase, "/") + if !strings.HasSuffix(apiBase, "/api") { + apiBase += "/api" + } + credentials := &JumpCloudCredentials{ clientConfig: config, httpClient: httpClient, @@ -64,7 +89,7 @@ func NewJumpCloudManager(config JumpCloudClientConfig, appMetrics telemetry.AppM } return &JumpCloudManager{ - client: client, + apiBase: apiBase, apiToken: config.APIToken, httpClient: httpClient, credentials: credentials, @@ -78,37 +103,58 @@ func (jc *JumpCloudCredentials) Authenticate(_ context.Context) (JWTToken, error return JWTToken{}, nil } -func (jm *JumpCloudManager) authenticationContext() context.Context { - return context.WithValue(context.Background(), v1.ContextAPIKey, v1.APIKey{ - Key: jm.apiToken, - }) -} - -// UpdateUserAppMetadata updates user app metadata based on userID and metadata map. -func (jm *JumpCloudManager) UpdateUserAppMetadata(_ context.Context, _ string, _ AppMetadata) error { - return nil -} - -// GetUserDataByID requests user data from JumpCloud via ID. -func (jm *JumpCloudManager) GetUserDataByID(_ context.Context, userID string, appMetadata AppMetadata) (*UserData, error) { - authCtx := jm.authenticationContext() - user, resp, err := jm.client.SystemusersApi.SystemusersGet(authCtx, userID, contentType, accept, nil) +// doRequest executes an HTTP request against the JumpCloud V1 API. +func (jm *JumpCloudManager) doRequest(ctx context.Context, method, path string, body io.Reader) ([]byte, error) { + reqURL := jm.apiBase + path + req, err := http.NewRequestWithContext(ctx, method, reqURL, body) if err != nil { return nil, err } + + req.Header.Set("x-api-key", jm.apiToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := jm.httpClient.Do(req) + if err != nil { + if jm.appMetrics != nil { + jm.appMetrics.IDPMetrics().CountRequestError() + } + return nil, err + } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountRequestStatusError() } - return nil, fmt.Errorf("unable to get user %s, statusCode %d", userID, resp.StatusCode) + return nil, fmt.Errorf("JumpCloud API request %s %s failed with status %d", method, path, resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +// UpdateUserAppMetadata updates user app metadata based on userID and metadata map. +func (jm *JumpCloudManager) UpdateUserAppMetadata(_ context.Context, _ string, _ AppMetadata) error { + return nil +} + +// GetUserDataByID requests user data from JumpCloud via ID. +func (jm *JumpCloudManager) GetUserDataByID(ctx context.Context, userID string, appMetadata AppMetadata) (*UserData, error) { + body, err := jm.doRequest(ctx, http.MethodGet, "/systemusers/"+userID, nil) + if err != nil { + return nil, err } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountGetUserDataByID() } + var user jumpCloudUser + if err = jm.helper.Unmarshal(body, &user); err != nil { + return nil, err + } + userData := parseJumpCloudUser(user) userData.AppMetadata = appMetadata @@ -116,30 +162,20 @@ func (jm *JumpCloudManager) GetUserDataByID(_ context.Context, userID string, ap } // GetAccount returns all the users for a given profile. -func (jm *JumpCloudManager) GetAccount(_ context.Context, accountID string) ([]*UserData, error) { - authCtx := jm.authenticationContext() - userList, resp, err := jm.client.SearchApi.SearchSystemusersPost(authCtx, contentType, accept, nil) +func (jm *JumpCloudManager) GetAccount(ctx context.Context, accountID string) ([]*UserData, error) { + allUsers, err := jm.searchAllUsers(ctx) if err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - if jm.appMetrics != nil { - jm.appMetrics.IDPMetrics().CountRequestStatusError() - } - return nil, fmt.Errorf("unable to get account %s users, statusCode %d", accountID, resp.StatusCode) - } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountGetAccount() } - users := make([]*UserData, 0) - for _, user := range userList.Results { + users := make([]*UserData, 0, len(allUsers)) + for _, user := range allUsers { userData := parseJumpCloudUser(user) userData.AppMetadata.WTAccountID = accountID - users = append(users, userData) } @@ -148,27 +184,18 @@ func (jm *JumpCloudManager) GetAccount(_ context.Context, accountID string) ([]* // GetAllAccounts gets all registered accounts with corresponding user data. // It returns a list of users indexed by accountID. -func (jm *JumpCloudManager) GetAllAccounts(_ context.Context) (map[string][]*UserData, error) { - authCtx := jm.authenticationContext() - userList, resp, err := jm.client.SearchApi.SearchSystemusersPost(authCtx, contentType, accept, nil) +func (jm *JumpCloudManager) GetAllAccounts(ctx context.Context) (map[string][]*UserData, error) { + allUsers, err := jm.searchAllUsers(ctx) if err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - if jm.appMetrics != nil { - jm.appMetrics.IDPMetrics().CountRequestStatusError() - } - return nil, fmt.Errorf("unable to get all accounts, statusCode %d", resp.StatusCode) - } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountGetAllAccounts() } indexedUsers := make(map[string][]*UserData) - for _, user := range userList.Results { + for _, user := range allUsers { userData := parseJumpCloudUser(user) indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], userData) } @@ -176,6 +203,41 @@ func (jm *JumpCloudManager) GetAllAccounts(_ context.Context) (map[string][]*Use return indexedUsers, nil } +// searchAllUsers paginates through all system users using limit/skip. +func (jm *JumpCloudManager) searchAllUsers(ctx context.Context) ([]jumpCloudUser, error) { + var allUsers []jumpCloudUser + + for skip := 0; ; skip += jumpCloudSearchPageSize { + searchReq := map[string]int{ + "limit": jumpCloudSearchPageSize, + "skip": skip, + } + + payload, err := json.Marshal(searchReq) + if err != nil { + return nil, err + } + + body, err := jm.doRequest(ctx, http.MethodPost, "/search/systemusers", bytes.NewReader(payload)) + if err != nil { + return nil, err + } + + var userList jumpCloudUserList + if err = jm.helper.Unmarshal(body, &userList); err != nil { + return nil, err + } + + allUsers = append(allUsers, userList.Results...) + + if skip+len(userList.Results) >= userList.TotalCount { + break + } + } + + return allUsers, nil +} + // CreateUser creates a new user in JumpCloud Idp and sends an invitation. func (jm *JumpCloudManager) CreateUser(_ context.Context, _, _, _, _ string) (*UserData, error) { return nil, fmt.Errorf("method CreateUser not implemented") @@ -183,7 +245,7 @@ func (jm *JumpCloudManager) CreateUser(_ context.Context, _, _, _, _ string) (*U // GetUserByEmail searches users with a given email. // If no users have been found, this function returns an empty list. -func (jm *JumpCloudManager) GetUserByEmail(_ context.Context, email string) ([]*UserData, error) { +func (jm *JumpCloudManager) GetUserByEmail(ctx context.Context, email string) ([]*UserData, error) { searchFilter := map[string]interface{}{ "searchFilter": map[string]interface{}{ "filter": []string{email}, @@ -191,25 +253,26 @@ func (jm *JumpCloudManager) GetUserByEmail(_ context.Context, email string) ([]* }, } - authCtx := jm.authenticationContext() - userList, resp, err := jm.client.SearchApi.SearchSystemusersPost(authCtx, contentType, accept, searchFilter) + payload, err := json.Marshal(searchFilter) if err != nil { return nil, err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - if jm.appMetrics != nil { - jm.appMetrics.IDPMetrics().CountRequestStatusError() - } - return nil, fmt.Errorf("unable to get user %s, statusCode %d", email, resp.StatusCode) + body, err := jm.doRequest(ctx, http.MethodPost, "/search/systemusers", bytes.NewReader(payload)) + if err != nil { + return nil, err } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountGetUserByEmail() } - usersData := make([]*UserData, 0) + var userList jumpCloudUserList + if err = jm.helper.Unmarshal(body, &userList); err != nil { + return nil, err + } + + usersData := make([]*UserData, 0, len(userList.Results)) for _, user := range userList.Results { usersData = append(usersData, parseJumpCloudUser(user)) } @@ -224,20 +287,11 @@ func (jm *JumpCloudManager) InviteUserByID(_ context.Context, _ string) error { } // DeleteUser from jumpCloud directory -func (jm *JumpCloudManager) DeleteUser(_ context.Context, userID string) error { - authCtx := jm.authenticationContext() - _, resp, err := jm.client.SystemusersApi.SystemusersDelete(authCtx, userID, contentType, accept, nil) +func (jm *JumpCloudManager) DeleteUser(ctx context.Context, userID string) error { + _, err := jm.doRequest(ctx, http.MethodDelete, "/systemusers/"+userID, nil) if err != nil { return err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - if jm.appMetrics != nil { - jm.appMetrics.IDPMetrics().CountRequestStatusError() - } - return fmt.Errorf("unable to delete user, statusCode %d", resp.StatusCode) - } if jm.appMetrics != nil { jm.appMetrics.IDPMetrics().CountDeleteUser() @@ -247,11 +301,11 @@ func (jm *JumpCloudManager) DeleteUser(_ context.Context, userID string) error { } // parseJumpCloudUser parse JumpCloud system user returned from API V1 to UserData. -func parseJumpCloudUser(user v1.Systemuserreturn) *UserData { +func parseJumpCloudUser(user jumpCloudUser) *UserData { names := []string{user.Firstname, user.Middlename, user.Lastname} return &UserData{ Email: user.Email, Name: strings.Join(names, " "), - ID: user.Id, + ID: user.ID, } } diff --git a/management/server/idp/jumpcloud_test.go b/management/server/idp/jumpcloud_test.go index 1bfdcefcc..dc7a9cb6c 100644 --- a/management/server/idp/jumpcloud_test.go +++ b/management/server/idp/jumpcloud_test.go @@ -1,8 +1,15 @@ package idp import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/netbirdio/netbird/management/server/telemetry" @@ -44,3 +51,212 @@ func TestNewJumpCloudManager(t *testing.T) { }) } } + +func TestJumpCloudGetUserDataByID(t *testing.T) { + userResponse := jumpCloudUser{ + ID: "user123", + Email: "test@example.com", + Firstname: "John", + Middlename: "", + Lastname: "Doe", + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/systemusers/user123", r.URL.Path) + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "test-api-key", r.Header.Get("x-api-key")) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(userResponse) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + userData, err := manager.GetUserDataByID(context.Background(), "user123", AppMetadata{WTAccountID: "acc1"}) + require.NoError(t, err) + + assert.Equal(t, "user123", userData.ID) + assert.Equal(t, "test@example.com", userData.Email) + assert.Equal(t, "John Doe", userData.Name) + assert.Equal(t, "acc1", userData.AppMetadata.WTAccountID) +} + +func TestJumpCloudGetAccount(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/search/systemusers", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + var reqBody map[string]any + assert.NoError(t, json.NewDecoder(r.Body).Decode(&reqBody)) + assert.Contains(t, reqBody, "limit") + assert.Contains(t, reqBody, "skip") + + resp := jumpCloudUserList{ + Results: []jumpCloudUser{ + {ID: "u1", Email: "a@test.com", Firstname: "Alice", Lastname: "Smith"}, + {ID: "u2", Email: "b@test.com", Firstname: "Bob", Lastname: "Jones"}, + }, + TotalCount: 2, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + users, err := manager.GetAccount(context.Background(), "testAccount") + require.NoError(t, err) + assert.Len(t, users, 2) + assert.Equal(t, "testAccount", users[0].AppMetadata.WTAccountID) + assert.Equal(t, "testAccount", users[1].AppMetadata.WTAccountID) +} + +func TestJumpCloudGetAllAccounts(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := jumpCloudUserList{ + Results: []jumpCloudUser{ + {ID: "u1", Email: "a@test.com", Firstname: "Alice"}, + {ID: "u2", Email: "b@test.com", Firstname: "Bob"}, + }, + TotalCount: 2, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + indexedUsers, err := manager.GetAllAccounts(context.Background()) + require.NoError(t, err) + assert.Len(t, indexedUsers[UnsetAccountID], 2) +} + +func TestJumpCloudGetAllAccountsPagination(t *testing.T) { + totalUsers := 250 + allUsers := make([]jumpCloudUser, totalUsers) + for i := range allUsers { + allUsers[i] = jumpCloudUser{ + ID: fmt.Sprintf("u%d", i), + Email: fmt.Sprintf("user%d@test.com", i), + Firstname: fmt.Sprintf("User%d", i), + } + } + + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqBody map[string]int + assert.NoError(t, json.NewDecoder(r.Body).Decode(&reqBody)) + + limit := reqBody["limit"] + skip := reqBody["skip"] + requestCount++ + + end := skip + limit + if end > totalUsers { + end = totalUsers + } + + resp := jumpCloudUserList{ + Results: allUsers[skip:end], + TotalCount: totalUsers, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + indexedUsers, err := manager.GetAllAccounts(context.Background()) + require.NoError(t, err) + assert.Len(t, indexedUsers[UnsetAccountID], totalUsers) + assert.Equal(t, 3, requestCount, "should require 3 pages for 250 users at page size 100") +} + +func TestJumpCloudGetUserByEmail(t *testing.T) { + searchResponse := jumpCloudUserList{ + Results: []jumpCloudUser{ + {ID: "u1", Email: "alice@test.com", Firstname: "Alice", Lastname: "Smith"}, + }, + TotalCount: 1, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/search/systemusers", r.URL.Path) + assert.Equal(t, http.MethodPost, r.Method) + + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + assert.Contains(t, string(body), "alice@test.com") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(searchResponse) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + users, err := manager.GetUserByEmail(context.Background(), "alice@test.com") + require.NoError(t, err) + assert.Len(t, users, 1) + assert.Equal(t, "alice@test.com", users[0].Email) +} + +func TestJumpCloudDeleteUser(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/systemusers/user123", r.URL.Path) + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "test-api-key", r.Header.Get("x-api-key")) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"_id": "user123"}) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + err := manager.DeleteUser(context.Background(), "user123") + require.NoError(t, err) +} + +func TestJumpCloudAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + manager := newTestJumpCloudManager(t, server.URL) + + _, err := manager.GetUserDataByID(context.Background(), "user123", AppMetadata{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "401") +} + +func TestParseJumpCloudUser(t *testing.T) { + user := jumpCloudUser{ + ID: "abc123", + Email: "test@example.com", + Firstname: "John", + Middlename: "M", + Lastname: "Doe", + } + + userData := parseJumpCloudUser(user) + assert.Equal(t, "abc123", userData.ID) + assert.Equal(t, "test@example.com", userData.Email) + assert.Equal(t, "John M Doe", userData.Name) +} + +func newTestJumpCloudManager(t *testing.T, apiBase string) *JumpCloudManager { + t.Helper() + return &JumpCloudManager{ + apiBase: apiBase, + apiToken: "test-api-key", + httpClient: http.DefaultClient, + helper: JsonParser{}, + appMetrics: nil, + } +}