Skip to content

Filter-aware aliases: pre_filter enforcement + filtered BM25 statistics - #226

Open
DarshitChanpura wants to merge 3 commits into
mainfrom
feat/filter-aware-aliases
Open

DarshitChanpura wants to merge 3 commits into
mainfrom
feat/filter-aware-aliases

Conversation

@DarshitChanpura

Copy link
Copy Markdown
Owner

Summary

Adds filter-aware aliases to OpenSearch: a filtered alias can now enforce its
filter before query statistics and term enumeration are computed, instead of
only post-filtering the result set. When enabled, this closes a scoring /
term-dictionary side-channel that plain filtered aliases — and, by extension, the
security plugin's DLS post-filtering — leave open, at zero storage and full
freshness.

Personal feature branch on my fork. Opening this PR against my own main for
review/history, not (yet) upstream.

Background — the side-channel

A filtered alias narrows the result set to matching documents, but the query
still executes against the whole shard:

  • BM25 scoring uses corpus-wide collection statistics
    (idf = log(1 + (N - df + 0.5)/(df + 0.5)), where N/df span every document,
    including hidden ones). A term appearing only in hidden documents has an
    inflated df, which depresses its score. A restricted user who can write a
    document and read back its score can detect hidden terms — the ExactOracle.
  • Term enumeration (prefix/wildcard/fuzzy/match_phrase_prefix) expands
    against the full term dictionary — the PrefixOracle.

Both exist because the filter is applied after statistics and the term
dictionary have been consulted over all documents.

What this adds

A new enforcement setting on an alias add action:

Value Behavior
post_filter (default) Historical behavior — filter applied post-collection; statistics run over the whole shard. Unchanged.
pre_filter Filter applied before scoring; statistics reflect only the visible subset, so the side-channel does not leak.
POST /_aliases
{ "actions": [ { "add": {
    "index": "patients", "alias": "cardiology-view",
    "filter": { "term": { "dept": "cardiology" } },
    "enforcement": "pre_filter"
} } ] }

pre_filter has two scoring sub-behaviors:

  1. constant_score (default for pre_filter) — the query is wrapped in
    ConstantScoreQuery(BooleanQuery(MUST=query, FILTER=aliasFilter)); no IDF is
    computed, so there is no scoring channel to leak. Fits the common security
    case where ranking is not required.
  2. filtered_stats — real BM25 scoring, but CollectionStatistics
    (N_f, docCount_f) and TermStatistics (df_f, ttf_f) are computed over
    the visible-document bitset, so ranking works over the visible subset and still
    leaks nothing. Opt-in via the
    opensearch.filter_aware_alias.filtered_stats system property (read per
    request), keeping constant_score the default while this sub-behavior is
    experimental.

Design / data flow

REST enforcement -> IndicesAliasesRequest.AliasActions   (parse + version-gated wire)
  -> AliasAction.Add -> AliasMetadata                    (cluster state, version-gated)
  -> IndicesService.buildAliasFilter                     (metadata -> AliasFilter.Enforcement)
  -> AliasFilter.Enforcement (PRE_FILTER)
  -> DefaultSearchContext                                (constant-score wrap OR plain filter clause)
  -> ContextIndexSearcher                                (filtered CollectionStatistics/TermStatistics)
  • The enforcement field is on the wire only between nodes at V_3_8_0+; older
    nodes never see it and behave as post_filter, so mixed-version clusters are
    unaffected.
  • AliasFilter.Enforcement is @ExperimentalApi.
  • dfs_query_then_fetch: the DFS phase's per-shard statistics use the same
    filtered overrides, so coordinator-aggregated stats are filtered too. The
    filtered branch is evaluated before the aggregatedDfs branch; the three cases
    (non-dfs query, dfs-phase compute, dfs query-phase read) are documented in
    ContextIndexSearcher.

Testing

FilterAwareAliasIT proves the behavior on a live cluster:

  • testPreFilterClosesScoringSideChannelWhilePostFilterLeaks — through a
    post_filter alias a hidden-only term scores below a control term (the
    leak); through pre_filter they score identically (closed). Both views
    return the same visible set.
  • testPreFilterClosesPrefixScoringChannel — same contrast for a
    match_phrase_prefix expansion (the PrefixOracle scoring path).
  • testFilteredStatisticsMatchPhysicallyFilteredIndex — the filtered_stats
    BM25 score equals the score from a physically-filtered visible-only index
    (ground-truth df/N correctness, not merely "lower").

Regression: 101 tests across alias / rollover / dfs / ContextIndexSearcher
green; AliasFilterTests covers serialization + pre-3.8 wire BWC.

Scope / follow-ups (not in this PR)

  • Raw term-dictionary APIs (A5b) — terms / suggest / _termvectors with
    term statistics bypass the per-request query filter and are not covered;
    they need the enumeration itself intersected with the alias bitset. Documented
    in FILTER_AWARE_ALIASES.md.
  • Per-user templated filters + DLS-role bridge (A7/A8) — live in the
    opensearch-project/security repo; design is sketched in
    FILTER_AWARE_ALIASES.md. This PR provides the core mechanism they build on.
  • filtered_stats as a first-class wire-level scoring mode — currently gated
    behind a system property rather than an alias field.

Backward compatibility

Default is post_filter — existing aliases, queries, and mixed-version clusters
behave exactly as before. No default path changes.

See server/.../action/admin/indices/alias/FILTER_AWARE_ALIASES.md for the full
design note.

@DarshitChanpura
DarshitChanpura force-pushed the feat/filter-aware-aliases branch 4 times, most recently from 6c050b2 to 8ff81da Compare August 5, 2026 00:00
@DarshitChanpura

Copy link
Copy Markdown
Owner Author

End-to-end flow (one line per hop)

POST /_aliases {enforcement: pre_filter}
  → IndicesAliasesRequest.AliasActions.enforcement          [API: parse]
  → TransportIndicesAliasesAction → AliasAction.Add         [API → cluster state]
  → AliasMetadata.enforcement (persisted in cluster state)  [cluster state]
  ── later, a search arrives on the alias ──
  → IndicesService.buildAliasFilter: "pre_filter" → PRE_FILTER   [bridge: string → enum]
  → AliasFilter(enforcement=PRE_FILTER) travels to each shard    [wire, version-gated]
  → DefaultSearchContext: fork on enforcement + filtered_stats   [behavior]
       ├─ post_filter          → filters.add(aliasFilter)        (unchanged; default)
       ├─ pre_filter + constant_score → ConstantScoreQuery wrap  (no IDF computed)
       └─ pre_filter + filtered_stats → filter clause + ContextIndexSearcher filtered N/df
  → (dfs_query_then_fetch) DfsPhase computes per-shard stats via the same filtered overrides

Where the substance is: most files just carry the enforcement flag from the REST
API down to the shard. The actual behavior lives in three places:

  • DefaultSearchContext — the fork above.
  • ContextIndexSearcher — builds a visible-doc bitset from the alias filter and overrides
    collectionStatistics() / termStatistics() to compute N/df over only that bitset
    (the filtered_stats path).
  • DfsPhase — makes the per-shard DFS statistics use those same filtered overrides so
    dfs_query_then_fetch aggregates visible-only numbers across shards.

Default is post_filter — existing aliases, queries, and mixed-version clusters are
unaffected. pre_filter's two scoring sub-behaviors have different cost profiles:
constant_score skips IDF entirely; filtered_stats does per-term postings∩bitset work
and is not yet performance-benchmarked.

…hange)

Adds the API scaffolding for the filter-aware-alias work without changing
any query behavior:

- new @experimentalapi enum AliasFilter.Enforcement { POST_FILTER, PRE_FILTER }
- optional, non-null enforcement field on AliasFilter, defaulting to
  POST_FILTER so every existing caller and the two-arg constructor are
  unaffected
- wire-format is version-gated (V_3_8_0+): older peers neither read nor
  write the field and receivers default to POST_FILTER, so mixed-version
  clusters keep today's behavior
- enforcement participates in equals/hashCode/toString

PRE_FILTER is recorded and serialized only; no code path acts on it yet.
The behavior (applying the filter before scoring so BM25 statistics reflect
only the visible subset) lands in a follow-up.

Tests: default-is-POST_FILTER, same-version round-trip, pre-3.8 BWC
default, and enforcement-in-equality. 5/5 green in AliasFilterTests.

Signed-off-by: Darshit Chanpura <dchanp@amazon.com>
…sible subset

Threads a new 'enforcement' setting (post_filter | pre_filter) end-to-end
through the alias pipeline and, when pre_filter is set, applies the alias
filter BEFORE scoring so BM25 collection statistics (N, df) reflect only the
documents the alias admits. This makes relevance scoring a function of the
visible subset alone, whereas post-filtering (the default) scores over the
whole shard and filters afterward.

Layers:
- REST/transport: 'enforcement' on IndicesAliasesRequest.AliasActions
  (parsed, xcontent, version-gated wire format at V_3_8_0)
- cluster state: enforcement on AliasAction.Add -> AliasMetadata (gated wire
  + xcontent + equals/hashCode)
- bridge: IndicesService.buildAliasFilter maps any resolved alias marked
  pre_filter to AliasFilter.Enforcement.PRE_FILTER
- search: DefaultSearchContext wraps the query in
  ConstantScoreQuery(MUST=query, FILTER=aliasFilter) for pre_filter; the
  default post_filter path is unchanged

Default is post_filter, so existing behavior and mixed-version clusters are
unaffected. This is the constant-score increment; filtered CollectionStatistics
/ TermStatistics, prefix scoring, and dfs aggregation are follow-ups.

Test: FilterAwareAliasIT proves, on a live cluster, that a term confined to
the filtered-out subset scores the same as an absent term through a pre_filter
alias, while through a post_filter alias it scores lower; both views return the
same visible set.

Signed-off-by: Darshit Chanpura <dchanp@amazon.com>
Completes the A-track for pre_filter aliases beyond the constant_score
increment.

A3/A4 - filtered statistics (ContextIndexSearcher):
  When an alias is enforced pre_filter with the filtered_stats scoring
  sub-behavior, collectionStatistics(field) and termStatistics(term) are
  computed over a lazily-built, live-docs-aware visible-doc bitset derived
  from the alias filter, so BM25 relevance ranking reflects only the visible
  subset. Guarded by Lucene's stats invariants (null for absent field/term;
  ttf>=df; sumDocFreq>=docCount).

A6 - dfs_query_then_fetch: the DFS phase's per-shard statistics use the same
  filtered overrides, so coordinator-aggregated stats also reflect the visible
  subset. The filtered branch is evaluated before the aggregatedDfs branch; the
  three cases (non-dfs, dfs-phase compute, dfs query-phase read) are documented.

Scoring-mode gate: filtered_stats is opt-in via the
  opensearch.filter_aware_alias.filtered_stats system property, read per
  request (not a static final) so it is toggleable without a JVM restart;
  constant_score remains the default pre_filter behavior.

Prefix scoring: the query-path match_phrase_prefix score also reflects only
  the visible subset under pre_filter. Raw term-dictionary APIs (terms/suggest/
  _termvectors) are a separate surface, not covered here.

Tests - FilterAwareAliasIT proves, on a live cluster: under pre_filter a term
  confined to the filtered-out subset scores the same as an absent term while
  under post_filter it scores lower; the same holds for prefix scoring; and the
  filtered_stats BM25 score EQUALS the score from a physically-filtered
  visible-only index (ground-truth N/df correctness).

Regression: 101 tests across alias/rollover/dfs/ContextIndexSearcher green.
Default behavior (post_filter) unchanged.

Signed-off-by: Darshit Chanpura <dchanp@amazon.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant