fix(api): Fix pagination limit, filters, and rows - #1115
Open
ShJ-code wants to merge 1 commit into
Open
Conversation
gtema
requested changes
Jul 29, 2026
gtema
left a comment
Collaborator
There was a problem hiding this comment.
It's a pretty interesting spot. Few comments though:
- PR descritption claims new tests 4 of which are not present in the diff: test_collect_authorized_page_refills_past_denied_items, test_collect_authorized_page_stops_when_backend_exhausted, test_collect_authorized_page_propagates_authorize_errors, test_collect_authorized_page_unlimited_filters_all
- pagination for credentials was previously skipping entries that are not allowed to be returned by the policy. Now it fetches next page(s) until it reaches the wanted page size - the mentioned tests would be really necessary. It is necessary to check all corner cases like all rows returned by backend are rejected by policy, second page returns less (or exactly equal) to the requested entities (verifying proper abort). It would be eventually also useful to implement a guard that guarantees no more than something like limit*2 of entries are tried before returning to prevent a full table scan. Also please add this behavior change into the PR description since it is a significant change.
Otherwise it is really a good change
Three defects in the shared v3/v4 pagination helpers, which every paginated collection inherits. `PaginationQuery::limit` defaulted to `Some(20)`, so a request that omits `?limit` still arrived with one. `Config::resolve_list_limit` only consults the configured values when the request carries no limit, so every per-provider and global `list_limit` was dead config, contrary to the precedence chain ADR 0029 documents. The default is now `None`, which also matches python keystone, whose `[DEFAULT] list_limit` is unset. The paginators then read `query.limit` — the raw client value — while the handler had fetched `resolve_list_limit(...) + 1` rows. A request for `limit=100` against a `max_list_limit` of 10 therefore returned all 11 fetched rows, exceeding the cap, and emitted no `next` link. Both paginators now resolve the effective limit themselves from the provider's `ListLimitConfig`, so the slice, the link, and the fetch cannot diverge. `build_pagination_link` rebuilt the href from the pagination controls alone, dropping every resource filter, so following the link from `GET /v3/policies?type=application/json` silently widened page two to the unfiltered collection. Non-pagination query parameters are now carried over verbatim, and the paginators take the request URI rather than only its path. Separately, a list endpoint that re-checks each item against the per-item read policy (security model I8) cannot over-fetch `limit + 1` rows just once: if any are filtered out, the surviving count can fall to `limit` or below while more visible rows remain, which the paginator reads as "no next page". `collect_authorized_page` keeps pulling batches, advancing the marker over raw rows, until `limit + 1` authorized items are collected or the backend is exhausted. `identity/credential/list` adopts it too. `ResourceIdentifier` is implemented on the *domain* credential so the per-item check still receives exactly the object it received before, leaving the policy input for that endpoint unchanged. That refill is bounded: at most `limit * AUTHORIZED_PAGE_SCAN_FACTOR` (2) raw rows are examined per request. Without a bound, a caller who may read only a sparse subset of a large collection would walk the whole table on every request, which is a cheap way to force a full scan. BEHAVIOUR CHANGE: `GET /v3/credentials` previously dropped the entries a caller could not read and returned a short page, ending pagination early. It now refills from subsequent rows up to the page size. A caller that could see 3 of 20 credentials used to receive 3 items and no `next` link; it now receives a full page and continues paging. Callers relying on the old short pages will see more entries per response. When the budget stops the scan before the page fills, the page is still short but `next` is emitted anyway, keyed on the last authorized item, so the caller keeps paging instead of treating it as the end of the collection. If no authorized item was found at all there is no marker that would not disclose an object the caller may not read, so no link is emitted and the truncation is logged. Signed-off-by: Sihao Jiang <sihao_jiang@outlook.com>
ShJ-code
force-pushed
the
fix/pagination-effective-limit
branch
from
July 30, 2026 01:08
152b681 to
d7f448b
Compare
gtema
requested changes
Jul 30, 2026
| let calls = calls as *const std::cell::Cell<usize>; | ||
| move |marker| { | ||
| // SAFETY-free: `calls` outlives the closure inside each test body. | ||
| unsafe { &*calls }.set(unsafe { &*calls }.get() + 1); |
Collaborator
There was a problem hiding this comment.
you found another issue - the crate does not apply overall workspace rules which forbid use of unsafe code (I will fix that). Rc<Cell> shared between the helper and the returned closure would avoid unsafe entirely and should be simpler.
Collaborator
Author
There was a problem hiding this comment.
Sorry for my oversight and thanks for fixing the issue. Is there anything else that I should make a change? I will check the code against the safety standard later again.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three defects in the shared v3/v4 pagination helpers (
crates/keystone/src/api/common.rs),which every paginated collection inherits. A prerequisite of #1035; split
out because none of it is policy-specific.
1. Per-resource
list_limitwas dead config.PaginationQuery::limitdefaulted to
Some(20), so a request that omitted?limitstill arrived withone.
Config::resolve_list_limitonly consults the configured values when therequest carries no limit, so every per-provider and global
list_limitwassilently ignored — contrary to the precedence chain ADR 0029 documents ("user
limit → per-resource → global
list_limit→max_db_limit"). The default isnow
None, which also matches python keystone, whose[DEFAULT] list_limitisunset out of the box.
2. The paginators used the raw client limit, not the effective one.
Handlers fetch
resolve_list_limit(...) + 1rows, butpaginate_forward/paginate_bidirectionalcompared againstquery.limit. A request forlimit=100against amax_list_limitof 10 therefore returned all 11 fetchedrows — exceeding the cap — and emitted no
nextlink, silently endingpagination. Both paginators now resolve the effective limit themselves from the
provider's
ListLimitConfig, so the fetch, the slice, and the link cannotdiverge.
federation/identity_provider/listadditionally passed the raw limitto the backend and only read config afterwards, so an unbounded query read the
whole table before truncating; it now resolves up front. All 19 list handlers
were audited — federation was the only one.
3.
nextlinks dropped resource filters.build_pagination_linkrebuiltthe href from the pagination controls alone, so following the link from
GET /v3/policies?type=application/jsonsilently widened page two to theunfiltered collection. Non-pagination query parameters are now carried over
verbatim; the paginators take the request URI rather than only its path.
Separately, a list endpoint that re-checks each item against the per-item read
policy (security model I8) cannot over-fetch
limit + 1rows just once: if anyare filtered out, the surviving count can fall to
limitor below while morevisible rows remain, which the paginator reads as "no next page".
collect_authorized_pagekeeps pulling batches — advancing the marker over rawrows — until
limit + 1authorized items are collected or the backend isexhausted.
identity/credential/listadopts it.Behaviour change worth flagging
With no configuration, omitting
?limitnow returns the full collection insteadof an implicit 20-item page. That is the ADR 0029 / python-keystone default;
deployments wanting a cap should set
[DEFAULT] list_limit(which now actuallyworks).
Test plan
All measured at
eb8a2b9dwith only this commit applied (baseupstream/main8fc78201):cargo test -p openstack-keystone --lib— 820 passed, 0 failedcargo nextest run -p test_integration --profile ci— 194 passed, 106 raft-skippedcargo nextest run --profile api -p test_api --test integration_api_v3— 117 passed, 0 failedopa test policy— 341/341cargo fmt --check,pre-commit run --all-files— clean10 new tests, covering each defect:
test_paginate_forward_unlimited_by_defaulttest_paginate_forward_uses_provider_default_limitlist_limitis live configtest_paginate_forward_clamps_to_max_list_limittest_paginate_forward_link_keeps_filters_with_effective_limitlimit=test_list_backend_gets_global_default_limittest_list_backend_limit_clamped_to_max_db_limittest_collect_authorized_page_refills_past_denied_itemstest_collect_authorized_page_stops_when_backend_exhaustedtest_collect_authorized_page_propagates_authorize_errorstest_collect_authorized_page_unlimited_filters_allTwo pre-existing federation tests were updated: they asserted the backend saw
limit: Some(20)for requests sending no?limit, which was the bug.Security review checklist
Applies: the diff touches
identity/credential/list, an I8 per-itemauthorization handler.
.regochanges;opa testunchanged at 341/341.ScopeInfovariant or auth method? (I5, Gate J) — None. Gate J passes.markeris client-supplied, andcollect_authorized_pageadvances it over raw rows including unauthorized ones. That marker is internal only: the emittednextlink is built fromitems.last()of the authorized, truncated page, so no unauthorized object's id is ever disclosed.ResourceIdentifieris implemented on the domain credential specifically socredential_policy_input(&item)still receives the identical object; converting to the API type first would have altered it. Gate I passes.test_list_denied_drops_all_itemsstill passes through the new helper...._propagates_authorize_errors(failure ≠ denial) and..._refills_past_denied_items(denied rows excluded from results while still advancing the marker), plus the pre-existingtest_list_denied_drops_all_items.ValidatedSecurityContext::new_for_scope()end-to-end? — N/A; no scope resolution is involved.