fix(client): page through SearchEntitlements results - #152
Conversation
SearchEntitlements sent page_size=100 and then made exactly one call, never reading NextPageToken, so every entitlement past the first page was silently dropped. The server has always documented this endpoint as paginated; it just never enforced it, so the bug was invisible. Loop until the token is empty, converting each page before moving on. The per-page conversion is load-bearing: the response's expanded array is indexed per response, so ExpandedMap offsets only resolve against the page they came from. Merging raw pages first would attach the wrong app or resource to each entitlement. Stop on an empty token rather than a short page. The server applies granted-status filtering after it cuts the page, so an intermediate page can be empty while later pages still hold results. A nil list is now an empty page rather than an error. The wire format omits an empty repeated field, so a search matching nothing arrives as a nil list and previously failed with "search-entitlements: list is nil". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| if nextPageToken == pageToken { | ||
| repeatedToken++ | ||
| if repeatedToken >= maxRepeatedSearchEntitlementsPageToken { | ||
| return nil, fmt.Errorf("search-entitlements: page token repeated %d times, pagination is not advancing", repeatedToken) | ||
| } | ||
| } else { | ||
| repeatedToken = 0 | ||
| } | ||
| pageToken = nextPageToken |
There was a problem hiding this comment.
🟡 Suggestion: the guard only catches a token that repeats the immediately preceding one — repeatedToken resets to 0 on any change, so a server cycling tok1 → tok2 → tok1 → tok2 loops forever and rv grows unbounded. Since this is the only bound on the loop, consider also capping total pages (or total results) as a backstop, e.g. if pages++; pages > maxPages { return nil, fmt.Errorf(...) }. Confidence: medium — requires a misbehaving server, but it's the difference between a bounded error and a hung CLI.
| rv := make([]*EntitlementWithBindings, 0, len(expandableList)) | ||
| for _, v := range expandableList { | ||
| rv = append(rv, &EntitlementWithBindings{ | ||
| Entitlement: AppEntitlement(*v.Entitlement.AppEntitlement), |
There was a problem hiding this comment.
🟡 Suggestion (pre-existing, now on a hotter path): NewExpandableEntitlementWithBindings only checks v.Entitlement == nil, but AppEntitlementView.AppEntitlement is itself a *AppEntitlement that the wire format omits when empty. A response containing {"entitlement":{}} makes this deref panic. Worth adding v.Entitlement.AppEntitlement == nil to the nil check in NewExpandableEntitlementWithBindings (or skipping such entries) — paginating multiplies the number of pages that must all be well-formed. Confidence: high on the deref, low on how likely the server is to emit it.
| func serveEntitlementPages(t *testing.T, pages []entitlementPage) (*httptest.Server, *[]string) { | ||
| t.Helper() | ||
| seen := make([]string, 0, len(pages)) | ||
| call := 0 |
There was a problem hiding this comment.
🟡 Suggestion: seen and call are written from the httptest handler goroutine and read from the test goroutine after SearchEntitlements returns, with no synchronization establishing that ordering. Requests are sequential so this is unlikely to misbehave, but it can trip go test -race. A sync.Mutex around both (and in a small accessor returning a copy of seen) would make it airtight. Same pattern in TestSearchEntitlementsStopsOnStuckPageToken with calls. Confidence: low-medium.
General PR Review: fix(client): page through SearchEntitlements resultsBlocking Issues: 0 | Suggestions: 3 | Threads Resolved: 0 Review SummaryScanned the full PR diff for security and correctness: the change replaces a single-shot Security IssuesNone found. Correctness IssuesNone found. Suggestions
Prompt for AI agents |
Why
client.SearchEntitlementssendspage_size: 100and then makes exactly one call, never readingNextPageToken— the body carried a literal// TODO(morgabra) Pagination. Every entitlement past the first page is silently dropped.This is invisible today only because the server ignores
page_sizeand returns everything in one response. The proto has always documented the endpoint as paginated (page_size,page_token,next_page_token, "the server returns one page of results and the nextPageToken until all results are retrieved"). The moment the server honors that, everyconecaller truncates at 100 with no error.That server change is ductone/c1 (SearchEntitlements pagination, flag-gated and off by default). This PR must land and ship before that flag is turned on for any tenant.
Affects
cone search,cone aws, andcone generate-alias, which all go through this function.What
Loop until the page token is empty, converting each page before moving on.
expandedarray is indexed per response —ExpandedMapholds offsets into that page's expanded objects. Concatenating raw pages and expanding once would resolve those offsets against the wrong array and attach the wrong app/resource to each entitlement. The existing expansion body is nowconvertSearchEntitlementsPage, called once per page.granted_statusfiltering after cutting the page, so an intermediate page can be empty while later pages still hold results. Breaking on a short page would reintroduce the truncation in a subtler form.{}and today fails withsearch-entitlements: list is nilinstead of reporting no results. Worth a second opinion — it's a behavior change beyond pagination.MaxRepeatedPageTokenLimit, so a server that never advances the token can't spin the client forever.Testing
Four tests drive the real loop against an
httptestserver (no testify — this repo doesn't depend on it):FollowsNextPageToken["", "tok1", "tok2"]DoesNotStopOnEmptyPageEmptyResultIsNotAnErrorStopsOnStuckPageTokenMutation-verified: forcing the loop to break after one iteration (the old behavior) fails three of the four.
go build ./..., the fullpkg/clientsuite,go vet, andgolangci-lint run ./pkg/client/...(0 issues) all pass.Note
ConductorOne/cone-privatecarries its own copy of this function with the same bug; it's fixed in a companion PR (it's on an older SDK, so the diff differs).🤖 Generated with Claude Code