Skip to content

Azure AI Search #141

@alexolivier

Description

@alexolivier

Azure AI Search Query Plan Adapter - Design Analysis

Summary

Feasibility analysis for a Cerbos query plan adapter targeting Azure AI Search (OData filter output). The adapter would convert PlanResourcesResponse into OData filter strings for use with @azure/search-documents.

Operator Coverage

Full Support (direct 1:1 mapping)

Cerbos OData
eq/ne/lt/le/gt/ge eq/ne/lt/le/gt/ge
and/or/not and/or/not
in search.in(field, 'v1,v2', ',')
isSet field ne null / field eq null
exists collection/any(x: expr)
all collection/all(x: expr)
filter Same as exists

Partial Support

Cerbos OData Workaround Issue
exists_one Approximate as any() No "exactly one" semantics in OData
hasIntersection (simple) Expand to any(x: x eq v1) or any(x: x eq v2) Verbose, O(n) clauses

Blocked (no OData equivalent)

Cerbos Why
contains No string containment in OData filters*
startsWith No native support in OData filters*
endsWith No native support in OData filters*
map OData filters don't support projections
hasIntersection + map Requires projection

*Azure AI Search may support OData string functions (startswith(), endswith()) - needs verification against actual API. If supported, these move to "Full Support."

Output Format

Recommendation: String output (OData filter string)

Rationale:

  • OData is inherently string-based
  • Matches how @azure/search-documents consumes filters: searchClient.search(query, { filter: "status eq 'active'" })
  • Drizzle adapter already precedents non-object output (returns SQL builder type)
  • Simpler than maintaining a separate AST + serializer

Value escaping required: single-quote doubling ('''), null handling, date ISO formatting.

Collection/Lambda Mapping

Maps well. Cerbos exists(tags, t, t.name == "public") → OData tags/any(t: t/name eq 'public').

Key differences:

  • Field separator: . (Cerbos) → / (OData)
  • Lambda syntax: positional args (Cerbos) → variable: expr (OData)
  • Nested collections supported: categories/any(c: c/tags/any(t: t/name eq 'public'))

Scoped mapper pattern from existing adapters applies directly.

Mapper Design

Simpler than ORM adapters - Azure AI Search fields are paths, not relation graphs.

type AzureSearchMapperConfig = {
  field?: string;        // OData field path (e.g., "metadata/author")
  collection?: boolean;  // marks as collection for any()/all()
};

type Mapper = Record<string, AzureSearchMapperConfig> | ((key: string) => AzureSearchMapperConfig);

No relation concept needed - Azure AI Search has complex types but no joins.

SDK Integration Point

import { queryPlanToAzureAISearch } from "@cerbos/azure-ai-search";

const result = queryPlanToAzureAISearch({ queryPlan, mapper });

if (result.kind === PlanKind.ALWAYS_DENIED) return [];

const searchOptions = result.kind === PlanKind.CONDITIONAL
  ? { filter: result.filter }
  : {};

const results = await searchClient.search(query, searchOptions);

Works with both keyword search and vector search (filter applied as pre-filter or post-filter on vector results).

Key Risks

  1. String operator gap - If policies use contains/startsWith/endsWith, the adapter cannot faithfully represent them. search.ismatch() is a possible fallback but has different semantics (full-text vs exact substring).

  2. OData clause limits - Azure AI Search has undocumented limits on filter complexity. Large hasIntersection expansions could hit these.

  3. exists_one semantic loss - Approximating as exists changes authorization semantics (allows access to resources that should be denied). May need to throw instead of approximate.

Open Questions

  • Does Azure AI Search support OData string functions (startswith(), endswith(), contains()) in $filter? This significantly affects operator coverage.
  • What are the actual clause count limits for OData filters in Azure AI Search?
  • Should exists_one throw or approximate? Approximation silently changes authz semantics.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions