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
-
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 hasIntersection expansions could hit these.
-
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.
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
PlanResourcesResponseinto OData filter strings for use with@azure/search-documents.Operator Coverage
Full Support (direct 1:1 mapping)
eq/ne/lt/le/gt/geeq/ne/lt/le/gt/geand/or/notand/or/notinsearch.in(field, 'v1,v2', ',')isSetfield ne null/field eq nullexistscollection/any(x: expr)allcollection/all(x: expr)filterexistsPartial Support
exists_oneany()hasIntersection(simple)any(x: x eq v1) or any(x: x eq v2)Blocked (no OData equivalent)
containsstartsWithendsWithmaphasIntersection+map*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:
@azure/search-documentsconsumes filters:searchClient.search(query, { filter: "status eq 'active'" })SQLbuilder type)Value escaping required: single-quote doubling (
'→''), null handling, date ISO formatting.Collection/Lambda Mapping
Maps well. Cerbos
exists(tags, t, t.name == "public")→ ODatatags/any(t: t/name eq 'public').Key differences:
.(Cerbos) →/(OData)variable: expr(OData)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.
No
relationconcept needed - Azure AI Search has complex types but no joins.SDK Integration Point
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
startswith(),endswith(),contains()) in$filter? This significantly affects operator coverage.exists_onethrow or approximate? Approximation silently changes authz semantics.