Skip to content

Commit cece004

Browse files
svnltoaaearon
andauthored
fix: paginate all SCA list endpoints via nextToken (#38)
* fix: paginate all SCA list endpoints via nextToken ListEligibility, ListSessions, and ListGroupsEligibility only fetched the first page of results. The API paginates at 50 items with nextToken continuation, so users with >50 targets/sessions/groups saw truncated data silently. All three methods now loop on nextToken, accumulating results across pages. A maxPages guard (100) prevents infinite loops if the API misbehaves. * refactor: extract generic paginate helper and fix pagination issues - Extract paginate[T] generic helper to DRY up the three copy-pasted pagination loops in ListEligibility, ListSessions, and ListGroupsEligibility - Capture Total from the first page instead of the last page - Remove unrealistic TestListEligibility_WithPagination (empty final page); TestListEligibility_Pagination already covers two-page scenario - Add TestListEligibility_PaginationMaxPagesExceeded to verify the maxPages safety cap returns an error - Add TestListEligibility_Pagination_TotalFromFirstPage to verify Total is taken from page 1 - Revert unrelated session_tracker_test.go change (belongs in PR #37); pin Store clock in test for deterministic behavior --------- Co-authored-by: Tim Schindler <tim@iosharp.com>
1 parent 6757fab commit cece004

3 files changed

Lines changed: 314 additions & 91 deletions

File tree

internal/cache/session_tracker_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import (
1010
func TestRecordSession_AndLookup(t *testing.T) {
1111
t.Parallel()
1212
s := NewStore(t.TempDir(), 25*time.Hour)
13-
now := time.Now().UTC().Truncate(time.Second)
13+
now := time.Date(2026, 2, 21, 12, 0, 0, 0, time.UTC)
14+
// Pin the Store clock so SessionTimestamps filtering is deterministic.
15+
s.now = func() time.Time { return now }
1416

1517
if err := RecordSession(s, "sess-1", now); err != nil {
1618
t.Fatalf("RecordSession() error = %v", err)

internal/sca/service.go

Lines changed: 108 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -101,27 +101,89 @@ func checkResponse(resp *http.Response, operation string) error {
101101
return fmt.Errorf("%s failed with status %d: %s", operation, resp.StatusCode, string(body))
102102
}
103103

104-
// ListEligibility retrieves eligible targets for the specified CSP.
104+
// maxPages is the upper bound on pagination requests to guard against infinite loops.
105+
const maxPages = 100
106+
107+
// paginate fetches all pages of a paginated GET endpoint using nextToken cursor.
108+
// buildParams returns the query parameters for each request (nextToken is added automatically).
109+
// decode extracts the items, nextToken pointer, and total from each page response.
110+
// The total is captured from the first page only.
111+
func paginate[T any](
112+
ctx context.Context,
113+
s *SCAAccessService,
114+
route string,
115+
buildParams func() map[string]string,
116+
decode func(io.Reader) ([]T, *string, int, error),
117+
errPrefix string,
118+
) (allItems []T, total int, _ error) {
119+
var nextToken *string
120+
121+
for page := range maxPages {
122+
params := buildParams()
123+
if nextToken != nil {
124+
if params == nil {
125+
params = make(map[string]string)
126+
}
127+
params["nextToken"] = *nextToken
128+
}
129+
130+
var p interface{}
131+
if len(params) > 0 {
132+
p = params
133+
}
134+
135+
resp, err := s.httpClient.Get(ctx, route, p)
136+
if err != nil {
137+
return nil, 0, fmt.Errorf("failed to get %s: %w", errPrefix, err)
138+
}
139+
140+
if err := checkResponse(resp, errPrefix+" request"); err != nil {
141+
resp.Body.Close()
142+
return nil, 0, err
143+
}
144+
145+
pageItems, nt, pageTotal, decErr := decode(resp.Body)
146+
resp.Body.Close()
147+
if decErr != nil {
148+
return nil, 0, fmt.Errorf("failed to decode %s response: %w", errPrefix, decErr)
149+
}
150+
151+
allItems = append(allItems, pageItems...)
152+
if page == 0 {
153+
total = pageTotal
154+
}
155+
nextToken = nt
156+
157+
if nextToken == nil {
158+
return allItems, total, nil
159+
}
160+
}
161+
162+
return nil, 0, fmt.Errorf("%s pagination exceeded maximum page limit", errPrefix)
163+
}
164+
165+
// ListEligibility retrieves all eligible targets for the specified CSP,
166+
// automatically paginating through all pages via nextToken.
105167
// GET /api/access/{CSP}/eligibility
106168
func (s *SCAAccessService) ListEligibility(ctx context.Context, csp models.CSP) (*models.EligibilityResponse, error) {
107169
route := fmt.Sprintf("/api/access/%s/eligibility", csp)
108170

109-
resp, err := s.httpClient.Get(ctx, route, nil)
171+
items, total, err := paginate(ctx, s, route,
172+
func() map[string]string { return nil },
173+
func(r io.Reader) ([]models.EligibleTarget, *string, int, error) {
174+
var page models.EligibilityResponse
175+
if err := json.NewDecoder(r).Decode(&page); err != nil {
176+
return nil, nil, 0, err
177+
}
178+
return page.Response, page.NextToken, page.Total, nil
179+
},
180+
"eligibility",
181+
)
110182
if err != nil {
111-
return nil, fmt.Errorf("failed to get eligibility: %w", err)
112-
}
113-
defer resp.Body.Close()
114-
115-
if err := checkResponse(resp, "eligibility request"); err != nil {
116183
return nil, err
117184
}
118185

119-
var result models.EligibilityResponse
120-
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
121-
return nil, fmt.Errorf("failed to decode eligibility response: %w", err)
122-
}
123-
124-
return &result, nil
186+
return &models.EligibilityResponse{Response: items, Total: total}, nil
125187
}
126188

127189
// Elevate requests JIT elevation for the specified targets.
@@ -180,55 +242,55 @@ func (s *SCAAccessService) RevokeSessions(ctx context.Context, req *models.Revok
180242
return &result, nil
181243
}
182244

183-
// ListSessions retrieves active elevated sessions, optionally filtered by CSP.
245+
// ListSessions retrieves all active elevated sessions, optionally filtered by CSP,
246+
// automatically paginating through all pages via nextToken.
184247
// GET /api/access/sessions
185248
func (s *SCAAccessService) ListSessions(ctx context.Context, csp *models.CSP) (*models.SessionsResponse, error) {
186-
route := "/api/access/sessions"
187-
188-
var params interface{}
189-
if csp != nil {
190-
params = map[string]string{"csp": string(*csp)}
191-
}
192-
193-
resp, err := s.httpClient.Get(ctx, route, params)
249+
items, total, err := paginate(ctx, s, "/api/access/sessions",
250+
func() map[string]string {
251+
if csp != nil {
252+
return map[string]string{"csp": string(*csp)}
253+
}
254+
return nil
255+
},
256+
func(r io.Reader) ([]models.SessionInfo, *string, int, error) {
257+
var page models.SessionsResponse
258+
if err := json.NewDecoder(r).Decode(&page); err != nil {
259+
return nil, nil, 0, err
260+
}
261+
return page.Response, page.NextToken, page.Total, nil
262+
},
263+
"sessions",
264+
)
194265
if err != nil {
195-
return nil, fmt.Errorf("failed to get sessions: %w", err)
196-
}
197-
defer resp.Body.Close()
198-
199-
if err := checkResponse(resp, "sessions request"); err != nil {
200266
return nil, err
201267
}
202268

203-
var result models.SessionsResponse
204-
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
205-
return nil, fmt.Errorf("failed to decode sessions response: %w", err)
206-
}
207-
208-
return &result, nil
269+
return &models.SessionsResponse{Response: items, Total: total}, nil
209270
}
210271

211-
// ListGroupsEligibility retrieves eligible Entra ID groups for the specified CSP.
272+
// ListGroupsEligibility retrieves all eligible Entra ID groups for the specified CSP,
273+
// automatically paginating through all pages via nextToken.
212274
// GET /api/access/{CSP}/eligibility/groups
213275
func (s *SCAAccessService) ListGroupsEligibility(ctx context.Context, csp models.CSP) (*models.GroupsEligibilityResponse, error) {
214276
route := fmt.Sprintf("/api/access/%s/eligibility/groups", csp)
215277

216-
resp, err := s.httpClient.Get(ctx, route, nil)
278+
items, total, err := paginate(ctx, s, route,
279+
func() map[string]string { return nil },
280+
func(r io.Reader) ([]models.GroupsEligibleTarget, *string, int, error) {
281+
var page models.GroupsEligibilityResponse
282+
if err := json.NewDecoder(r).Decode(&page); err != nil {
283+
return nil, nil, 0, err
284+
}
285+
return page.Response, page.NextToken, page.Total, nil
286+
},
287+
"groups eligibility",
288+
)
217289
if err != nil {
218-
return nil, fmt.Errorf("failed to get groups eligibility: %w", err)
219-
}
220-
defer resp.Body.Close()
221-
222-
if err := checkResponse(resp, "groups eligibility request"); err != nil {
223290
return nil, err
224291
}
225292

226-
var result models.GroupsEligibilityResponse
227-
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
228-
return nil, fmt.Errorf("failed to decode groups eligibility response: %w", err)
229-
}
230-
231-
return &result, nil
293+
return &models.GroupsEligibilityResponse{Response: items, Total: total}, nil
232294
}
233295

234296
// ElevateGroups requests JIT elevation for the specified Entra ID groups.

0 commit comments

Comments
 (0)