Ash extension that turns a resource into a unified, cross-entity search index — one row per indexed source object, searched with a single ranked query (Option B).
defmodule MyApp.Search.Document do
use Ash.Resource,
domain: MyApp.Search,
data_layer: AshPostgres.DataLayer,
extensions: [SearchAsh.GlobalIndex]
postgres do
table "search_documents"
repo MyApp.Repo
end
# Tenant-scope the index like any resource (optional):
multitenancy do
strategy :attribute
attribute :org_id
end
global_index do
default_language :fr
end
attributes do
uuid_primary_key :id
attribute :org_id, :string, allow_nil?: false, public?: true
end
endIt generates the index columns (source_type, source_id, language, search_text,
archived, label), a unique_source identity, a GIN index, an :upsert action, a
:search_rank calculation and a :global_search read action that filters + ranks
(ts_rank, prefix-aware) and returns (source_type, source_id, archived, label, rank).
It hides archived rows by default; pass include_archived?: true to get both.
Source resources feed it with the SearchAsh.Source extension. Existing data is
backfilled with SearchAsh.reindex/2.
Authorization
This index does not inherit the policies of the resources feeding it. It does honour
its own: :global_search is a plain Ash read action, so policies you put on this
resource compose with it. What they can authorize on is whatever an index row holds —
source_type, archived, label, language, and your tenant attribute.
So a role that gates which kinds of thing a user may see works today:
policies do
policy action_type(:read) do
authorize_if expr(source_type in ^actor(:visible_types))
end
endsource_type is stored as a string, so the actor's list must hold strings. Ash policies
need a SAT solver (:picosat_elixir or :simple_sat).
Row-level ownership does not. No owner_id, team, or per-record visibility flag can
reach an index row — SearchAsh.Source writes a fixed set of columns — so results would
carry the label of rows a user cannot open. Note that label_field is yours: point it
at a reference rather than at something sensitive and a result reveals that a match
exists, not what it says. Note too that this index answers "what may this user find",
not "what may they do" — routing to the object applies the source's policies, so do not
mirror write permissions here.
A result carries (source_type, source_id), so you can re-check rights when rendering.
That is sound as a safety net over a policy that already filters in SQL — it drops
almost nothing and ranking is untouched. It is not sound as the primary filter:
Postgres ranked and paginated over rows you then discard, so page 1 can come back empty
while the matches sit on page 5. Either way, count in the view — Ash.count on the
action counts what SQL matched, before any render-time filtering.
For real row-level read filtering, use per-resource SearchAsh (search do … end): it
queries the source table, so your policies apply, at the cost of cross-entity search.
Copying ACLs into the index is a trap — authorization facts change independently of
content, so nothing would trigger a re-index, and a stale index row is a security
incident rather than a cosmetic one.