Query plan adapter for Elasticsearch (Java)

Published by Alex Olivier on February 20, 2026
Query plan adapter for Elasticsearch (Java)

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/ne with null)
  • Arrays: hasIntersection, size comparisons
  • Collections: exists, all, hasIntersection + map (via nested queries)

 

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.

Book a free Policy Workshop to discuss your requirements and get your first policy written by the Cerbos team

What is Cerbos?

Cerbos is an end-to-end enterprise authorization software for Zero Trust environments and AI-powered systems. It enforces fine-grained, contextual, and continuous authorization across apps, APIs, AI agents, MCP servers, services, and workloads.

Cerbos consists of an open-source Policy Decision Point, Enforcement Point integrations, and a centrally managed Policy Administration Plane (Cerbos Hub) that coordinates unified policy-based authorization across your architecture. Enforce least privilege & maintain full visibility into access decisions with Cerbos authorization.