ash-project / ash-project/ash

Keyset cursor over a field-policy-redacted sort field encodes the redaction marker — page 2 silently returns no rows

Open
#2,786 7 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.