From a7639be86bc5c835a0e6bb2433efd0e34ea8792d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 19:10:18 +0000 Subject: [PATCH] Handle archived HubSpot users Parse HubSpot archived and deactivated status signals when building access-review account records. This preserves inactive users as explicit records instead of dropping them when status fields differ from the current payload assumptions. Also accept roleIds payloads and keep records that only expose an external ID, which is common for archived users with empty email fields. Add a focused unit test to lock in archived-user handling. Signed-off-by: Cursor Agent Co-authored-by: Bryan FRIMIN --- pkg/accessreview/drivers/hubspot.go | 98 ++++++++++++++++++------ pkg/accessreview/drivers/hubspot_test.go | 57 ++++++++++++++ 2 files changed, 130 insertions(+), 25 deletions(-) diff --git a/pkg/accessreview/drivers/hubspot.go b/pkg/accessreview/drivers/hubspot.go index 2edd12366..8325f8184 100644 --- a/pkg/accessreview/drivers/hubspot.go +++ b/pkg/accessreview/drivers/hubspot.go @@ -32,29 +32,39 @@ type HubSpotDriver struct { var _ Driver = (*HubSpotDriver)(nil) -type hubspotRolesResponse struct { - Results []struct { - ID string `json:"id"` - Name string `json:"name"` - } `json:"results"` -} +type ( + hubspotRolesResponse struct { + Results []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"results"` + } -type hubspotUsersResponse struct { - Results []struct { - ID string `json:"id"` - Email string `json:"email"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - RoleID string `json:"roleId"` - PrimaryTeamID string `json:"primaryTeamId"` - SuperAdmin bool `json:"superAdmin"` - } `json:"results"` - Paging *struct { - Next *struct { - After string `json:"after"` - } `json:"next"` - } `json:"paging"` -} + hubspotUser struct { + ID string `json:"id"` + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + RoleID string `json:"roleId"` + RoleIDs []string `json:"roleIds"` + PrimaryTeamID string `json:"primaryTeamId"` + SuperAdmin bool `json:"superAdmin"` + Archived *bool `json:"archived"` + Deactivated *bool `json:"deactivated"` + IsActive *bool `json:"isActive"` + Active *bool `json:"active"` + HSDeactivated *bool `json:"hs_deactivated"` + } + + hubspotUsersResponse struct { + Results []hubspotUser `json:"results"` + Paging *struct { + Next *struct { + After string `json:"after"` + } `json:"next"` + } `json:"paging"` + } +) const ( hubspotUsersEndpoint = "https://api.hubapi.com/settings/v3/users" @@ -83,9 +93,10 @@ func (d *HubSpotDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro for _, u := range resp.Results { role := "User" + roleID := hubspotRoleID(u) - if roleMap != nil && u.RoleID != "" { - if name, ok := roleMap[u.RoleID]; ok { + if roleMap != nil && roleID != "" { + if name, ok := roleMap[roleID]; ok { role = name } else if u.SuperAdmin { role = "Super Admin" @@ -100,6 +111,7 @@ func (d *HubSpotDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro Email: u.Email, FullName: fullName, Role: role, + Active: hubspotUserActive(u), IsAdmin: u.SuperAdmin, ExternalID: u.ID, MFAStatus: coredata.MFAStatusUnknown, @@ -107,7 +119,7 @@ func (d *HubSpotDriver) ListAccounts(ctx context.Context) ([]AccountRecord, erro AccountType: coredata.AccessEntryAccountTypeUser, } - if record.Email != "" { + if record.Email != "" || record.ExternalID != "" { records = append(records, record) } } @@ -193,3 +205,39 @@ func (d *HubSpotDriver) fetchRoles(ctx context.Context) (map[string]string, erro return roleMap, nil } + +func hubspotRoleID(user hubspotUser) string { + if user.RoleID != "" { + return user.RoleID + } + + if len(user.RoleIDs) > 0 { + return user.RoleIDs[0] + } + + return "" +} + +func hubspotUserActive(user hubspotUser) *bool { + if user.IsActive != nil { + return new(*user.IsActive) + } + + if user.Active != nil { + return new(*user.Active) + } + + if user.Deactivated != nil { + return new(!*user.Deactivated) + } + + if user.HSDeactivated != nil { + return new(!*user.HSDeactivated) + } + + if user.Archived != nil { + return new(!*user.Archived) + } + + return nil +} diff --git a/pkg/accessreview/drivers/hubspot_test.go b/pkg/accessreview/drivers/hubspot_test.go index c3a68c643..5c16dd9ce 100644 --- a/pkg/accessreview/drivers/hubspot_test.go +++ b/pkg/accessreview/drivers/hubspot_test.go @@ -16,7 +16,10 @@ package drivers import ( "context" + "io" + "net/http" "os" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -39,3 +42,57 @@ func TestHubSpotDriver(t *testing.T) { assert.NotEmpty(t, r.FullName) assert.NotEmpty(t, r.ExternalID) } + +func TestHubSpotDriverArchivedUsers(t *testing.T) { + t.Parallel() + + client := &http.Client{ + Transport: roundTripFunc( + func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case "/settings/v3/users/roles": + return hubspotResponse( + http.StatusOK, + `{"results":[{"id":"role-1","name":"Sales Admin"}]}`, + ), nil + case "/settings/v3/users": + return hubspotResponse( + http.StatusOK, + `{"results":[{"id":"user-1","email":"active@example.com","firstName":"Active","lastName":"User","roleIds":["role-1"],"superAdmin":false,"isActive":true},{"id":"user-2","email":"","firstName":"Archived","lastName":"User","superAdmin":false,"archived":true}]}`, + ), nil + default: + return hubspotResponse(http.StatusNotFound, `{"message":"not found"}`), nil + } + }, + ), + } + + driver := NewHubSpotDriver(client) + + records, err := driver.ListAccounts(context.Background()) + require.NoError(t, err) + require.Len(t, records, 2) + + assert.Equal(t, "Sales Admin", records[0].Role) + require.NotNil(t, records[0].Active) + assert.True(t, *records[0].Active) + + assert.Equal(t, "user-2", records[1].ExternalID) + assert.Empty(t, records[1].Email) + require.NotNil(t, records[1].Active) + assert.False(t, *records[1].Active) +} + +type roundTripFunc func(req *http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func hubspotResponse(statusCode int, body string) *http.Response { + return &http.Response{ + StatusCode: statusCode, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + } +}