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. Post-filtering the
results breaks ranking and pagination (you would filter after ranking, so a page can
come back empty), which is the standard denormalized-index problem. Use per-resource
SearchAsh (search do … end) there: it queries the source table, so your policies
apply, at the cost of cross-entity search. The extra_attrs hook on the roadmap is what
would close this.