Keyset cursor over a field-policy-redacted sort field encodes the redaction marker — page 2 silently returns no rows
- Dominant language
- Elixir
- Stars
- 2.5k
- Forks
- 422
- Avg merge
- 23h 26m
- Merged PRs (30d)
- 46
Description
**Describe the bug**
Keyset cursors are computed from the record **after** field policies redact it (`Ash.Page.Keyset.data_with_keyset/3` reads `Map.get(record, field)`). When the sort field is protected by a field policy the actor fails, the cursor encodes the redaction marker instead of the sort value:
```elixir
cursor |> Base.decode64!() |> :erlang.binary_to_term()
#=> [#Ash.NotLoaded<:attribute, field: :secret_score, ...>, ""]
```
Requesting the next page with that cursor then builds a keyset filter comparing rows against the marker term — on the ETS data layer page 2 **silently returns 0 results** while more rows exist (no error, pagination just ends early). On a data layer that pushes the comparison down, it surfaces as a confusing filter error carrying the `%Ash.ForbiddenField{}` struct. Either way a non-admin actor sorting by a field-policy-protected field cannot paginate past page 1, and the failure mode on the reference data layer is silent.
(There's also a small information-exposure angle: the cursor is `term_to_binary`+Base64, so whatever gets encoded — marker or value — is caller-decodable. Computing cursors from **pre-redaction** values would fix pagination but would leak the protected value through the cursor; rejecting a keyset sort over a field the actor can't read, or omitting the redacted field from the cursor with a loud error, avoids both.)
**To Reproduce**
```elixir
Mix.install([{:ash, "~> 3.29"}, {:simple_sat, "~> 0.1"}])
defmodule Repro.Doc do
use Ash.Resource,
domain: Repro.Domain,
data_layer: Ash.DataLayer.Ets,
authorizers: [Ash.Policy.Authorizer]
attributes do
uuid_primary_key :id
attribute :title, :string, public?: true
attribute :secret_score, :integer, public?: true
end
policies do
policy always() do
authorize_if always()
end
end
field_policies do
field_policy :secret_score do
authorize_if actor_attribute_equals(:admin, true)
end
field_policy :* do
authorize_if always()
end
end
actions do
default_accept [:title, :secret_score]
defaults [:create]
read :read do
primary? true
pagination keyset?: true, required?: false
end
end
end
defmodule Repro.Domain do
use Ash.Domain, validate_config_inclusion?: false
resources do
resource Repro.Doc
end
end
for i <- 1..4 do
Repro.Doc
|> Ash.Changeset.for_create(:create, %{title: "t#{i}", secret_score: i * 10},
authorize?: false
)
|> Ash.create!()
end
# Non-admin actor, sorted by the protected field:
page1 =
Repro.Doc
|> Ash.Query.sort(secret_score: :asc)
|> Ash.read!(actor: %{admin: false}, page: [limit: 2])
cursor = List.last(page1.results).__metadata__.keyset
IO.inspect(cursor |> Base.decode64!() |> :erlang.binary_to_term())
#=> [#Ash.NotLoaded<:attribute, field: :secret_score, ...>, ""]
page2 =
Repro.Doc
|> Ash.Query.sort(secret_score: :asc)
|> Ash.read!(actor: %{admin: false}, page: [limit: 2, after: cursor])
IO.inspect(length(page2.results))
#=> 0 (4 rows exist; 2 were returned on page 1 — pagination silently ends)
```
Observed on ash 3.29.3 / Elixir 1.19.5 / OTP 28.
**Expected behavior**
One of (maintainers' call):
1. A keyset-paginated read sorted by a field the actor cannot read is rejected loudly (clear error at page 1), or
2. the redacted field is excluded from the cursor with a loud error when the cursor can't be built —
rather than a cursor that encodes the redaction marker and silently terminates pagination.
**Context**
Found while building keyset support for an Ash data layer with fail-closed field-classification semantics; happy to help with a PR once a direction is picked.
Contributor guide
Research direction
Start with the supplied Mix.install reproduction and inspect Ash.Page.Keyset.data_with_keyset/3, which the issue identifies as reading the sort field from the redacted record. Confirm the cursor contents and page-2 behavior, then follow the maintainer-selected direction so protected sort fields fail loudly without encoding protected values.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- elixir
- Domain
- backend, security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100