SearchAsh.GlobalIndex (search_ash v0.2.2)

Copy Markdown View Source

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
end

It 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
end

source_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.

Summary

Functions

global_index(body)

(macro)