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:
cursor |> Base.decode64!() |> :erlang.binary_to_term()
#=> [#Ash.NotLoaded<:attribute, field: :secret_score, ...>, "<pk-uuid>"]
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
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, ...>, "<pk>"]
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):
- A keyset-paginated read sorted by a field the actor cannot read is rejected loudly (clear error at page 1), or
- 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.
Describe the bug
Keyset cursors are computed from the record after field policies redact it (
Ash.Page.Keyset.data_with_keyset/3readsMap.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: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
Observed on ash 3.29.3 / Elixir 1.19.5 / OTP 28.
Expected behavior
One of (maintainers' call):
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.