-
Notifications
You must be signed in to change notification settings - Fork 13
Description
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-documentsconsumes filters:searchClient.search(query, { filter: "status eq 'active'" }) - Drizzle adapter already precedents non-object output (returns
SQLbuilder 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
-
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). -
OData clause limits - Azure AI Search has undocumented limits on filter complexity. Large
hasIntersectionexpansions could hit these. -
exists_onesemantic loss - Approximating asexistschanges 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_onethrow or approximate? Approximation silently changes authz semantics.