Externalized authorization separates access control logic from application code. Instead of scattering if statements and role checks across your codebase, you define policies centrally and query a decision engine at runtime. Cerbos is purpose-built for this: your policies reside as code in version control, and applications call Cerbos to determine whether a given principal can perform a specific action on a particular resource.
This works well for individual access checks, but most applications also need to answer a different question: "which resources can this user see?" The naive approach -- fetch everything, then call checkResources on each row -- does not scale. You end up reading data that the user will never be allowed to see, wasting database I/O and application memory.
Cerbos solves this with the PlanResources API. Instead of evaluating a specific resource instance, PlanResources uses partial evaluation to analyze your policies and return a query plan - an abstract syntax tree (AST) that describes the conditions under which access is granted. The plan contains the same operators and attribute references that your policies use, but structured as a tree that can be mechanically translated into any query language. The result is one of three outcomes:
- Always allowed: the user can access every resource of this type, no filter needed.
- Always denied: the user has no access at all, return an empty set.
- Conditional: Cerbos returns an expression tree. Translate it into your database's filter syntax and let the database do the work.
The conditional case is where query plan adapters come in. They walk the AST, map Cerbos attribute paths to your schema's field names, and produce a native filter that your database client can execute directly. Authorization logic stays in your policies, and the database applies it at the query layer -- aligned with indexes and query optimization, not burning cycles in application code.
Today, we are releasing a query plan adapter for Elasticsearch.
Why Elasticsearch?
Elasticsearch is the backbone of search and analytics infrastructure across industries. When applications need to search, filter, and aggregate large volumes of data, Elasticsearch provides the query engine. However, authorization in Elasticsearch is typically handled at the application layer - fetching results and then filtering in code - or through Elasticsearch's own document-level security, which couples access rules to the cluster configuration rather than relying on application-level policies.
The adapter bridges this gap. Authorization policies stay in Cerbos, and the adapter translates them into native Elasticsearch Query DSL that runs inside the cluster, leveraging indexes, filter caching, and query optimization. No post-fetch filtering in application code.
How it works
ElasticsearchQueryPlanAdapter.toElasticsearchQuery takes a Cerbos PlanResourcesResult and a field map that associates Cerbos attribute paths with Elasticsearch field names. It walks the expression tree and returns a Map<String, Object> that serializes directly to Elasticsearch Query DSL JSON.
import dev.cerbos.queryplan.elasticsearch.ElasticsearchQueryPlanAdapter;
import dev.cerbos.queryplan.elasticsearch.ElasticsearchQueryPlanAdapter.Result;
Map<String, String> fieldMap = Map.of(
"request.resource.attr.status", "status",
"request.resource.attr.department", "department",
"request.resource.attr.ownerId", "ownerId"
);
PlanResourcesResult plan = cerbos.plan(
Principal.newInstance("user1", "USER"),
Resource.newInstance("document"),
"view"
);
Result result = ElasticsearchQueryPlanAdapter.toElasticsearchQuery(plan, fieldMap);
switch (result) {
case Result.AlwaysAllowed allowed -> {
// Search without authorization filters
}
case Result.AlwaysDenied denied -> {
// Return empty results
}
case Result.Conditional conditional -> {
// Use conditional.query() in a bool.filter clause
String json = objectMapper.writeValueAsString(
Map.of("query", Map.of(
"bool", Map.of("filter", List.of(conditional.query()))
))
);
}
}
Because the adapter returns a sealed Result type, the compiler enforces that every case is handled. The conditional branch produces a standard Elasticsearch query map that slots into a bool.filter clause -- keeping authorization out of relevance scoring so Elasticsearch can cache the filter and skip scoring overhead.
Nested object support
Real-world Elasticsearch schemas often contain arrays of nested objects -- tags with metadata, categories with subcategories, permissions with scopes. Cerbos policies can express conditions over these structures using collection operators like exists and all. The adapter translates these into Elasticsearch nested queries automatically.
Pass a Set<String> of field names that use Elasticsearch's nested mapping type:
Map<String, String> fieldMap = Map.of(
"request.resource.attr.status", "status",
"request.resource.attr.tags", "tags"
);
Set<String> nestedPaths = Set.of("tags");
Result result = ElasticsearchQueryPlanAdapter.toElasticsearchQuery(
plan, fieldMap, nestedPaths
);
The adapter resolves lambda variables inside collection operators to the correct nested field paths. A policy condition like R.attr.tags.exists(tag, tag.name == "public") becomes:
{
"nested": {
"path": "tags",
"query": {
"term": { "tags.name": { "value": "public" } }
}
}
}
The all operator uses a double-negation pattern -- "there is no nested document that fails the condition" -- which Elasticsearch evaluates efficiently:
{
"bool": {
"must_not": [{
"nested": {
"path": "tags",
"query": {
"bool": {
"must_not": [{ "term": { "tags.name": { "value": "public" } } }]
}
}
}
}]
}
}
hasIntersection combined with map projects a field from nested objects and checks for overlap with a value list, producing a nested + terms query.
Full example
Consider a policy that allows users to view published documents, documents they own, or documents tagged with a category they have access to:
# policies/document.yaml
apiVersion: api.cerbos.dev/v1
resourcePolicy:
resource: document
version: default
rules:
- actions: ["view"]
effect: EFFECT_ALLOW
roles: ["USER"]
condition:
match:
any:
of:
- expr: request.resource.attr.status == "published"
- expr: request.resource.attr.ownerId == request.principal.id
- expr: >
request.resource.attr.tags.exists(tag,
tag.category == "department" && tag.value == request.principal.attr.department)
With the adapter:
import dev.cerbos.queryplan.elasticsearch.ElasticsearchQueryPlanAdapter;
import dev.cerbos.queryplan.elasticsearch.ElasticsearchQueryPlanAdapter.Result;
Map<String, String> fieldMap = Map.of(
"request.resource.attr.status", "status",
"request.resource.attr.ownerId", "ownerId",
"request.resource.attr.tags", "tags"
);
Set<String> nestedPaths = Set.of("tags");
PlanResourcesResult plan = cerbos.plan(
Principal.newInstance("alice", "USER")
.withAttribute("department", "engineering"),
Resource.newInstance("document"),
"view"
);
Result result = ElasticsearchQueryPlanAdapter.toElasticsearchQuery(
plan, fieldMap, nestedPaths
);
List<Document> documents = switch (result) {
case Result.AlwaysAllowed ignored -> {
yield esClient.search(s -> s.index("documents"), Document.class)
.hits().hits().stream().map(Hit::source).toList();
}
case Result.AlwaysDenied ignored -> Collections.emptyList();
case Result.Conditional conditional -> {
// Combine authorization filter with a user search query
String queryJson = objectMapper.writeValueAsString(
Map.of("query", Map.of(
"bool", Map.of(
"must", List.of(
Map.of("match", Map.of("title", userSearchTerm))
),
"filter", List.of(conditional.query())
)
))
);
yield esClient.search(
s -> s.index("documents").withJson(new StringReader(queryJson)),
Document.class
).hits().hits().stream().map(Hit::source).toList();
}
};
The adapter produces a bool.should clause combining the three policy conditions -- a term query for status, a term query for owner, and a nested query for the tag match. Placed in bool.filter, Elasticsearch evaluates the authorization filter without scoring, caches it, and applies the user's relevance query separately in bool.must.
Custom operator overrides
The adapter ships with sensible defaults for every operator Cerbos can emit, but Elasticsearch schemas vary. If your string fields use text type instead of keyword, override the eq operator to use match instead of term:
Map<String, OperatorFunction> overrides = Map.of(
"eq", (field, value) -> Map.of("match", Map.of(field, value)),
"contains", (field, value) -> Map.of("match_phrase", Map.of(field, value))
);
Result result = ElasticsearchQueryPlanAdapter.toElasticsearchQuery(
plan, fieldMap, overrides
);
Supported operators
The adapter covers the full range of operators that Cerbos can emit in a query plan:
- Logical:
and,or,not - Comparison:
eq,ne,lt,gt,le,ge,in - String:
contains,startsWith,endsWith - Existence:
isSet, null checks (eq/newith null) - Arrays:
hasIntersection,sizecomparisons - Collections:
exists,all,hasIntersection+map(vianestedqueries)
Get started
Copy the source files directly into your Java project from GitHub. The adapter has two dependencies: cerbos-sdk-java and protobuf-java. Full documentation, integration examples, and the test suite are in the repository. If you have questions or run into issues, join the Cerbos community Slack.
Tagged in




