---
title: "Query plan adapter for Elasticsearch (Java)"
description: "Externalized authorization with Cerbos. Translate policies into native Elasticsearch Query DSL using the PlanResources API. Push access control to the database layer, avoid post-fetch filtering, and scale secure search with nested query support and operator overrides."
author: "Alex Olivier"
date: "2026-05-20T11:44:00.000Z"
canonical: "https://www.cerbos.dev/blog/query-plan-adapter-for-elasticsearch-java"
image: "https://stylish-appliance-1c1cc1c30d.media.strapiapp.com/Query_plan_adapter_for_Elasticsearch_Java_18104d6b98.png"
tags: ["documentation","engineering","guide"]
source: "https://www.cerbos.dev/blog/query-plan-adapter-for-elasticsearch-java"
---

# 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](https://docs.cerbos.dev/cerbos/latest/api/index.html#resources-query-plan). 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](https://www.elastic.co/elasticsearch).

<p>&nbsp;</p>

## 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.

<p>&nbsp;</p>

## 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.

```java
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.

<p>&nbsp;</p>

## 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:

```java
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:

```json
{
  "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:

```json
{
  "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.

<p>&nbsp;</p>

## 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:

```java
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`.

<p>&nbsp;</p>

## 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`:

```java
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
);
```

<p>&nbsp;</p>

## 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)

<p>&nbsp;</p>

## Get started

Copy the source files directly into your Java project from [GitHub](https://github.com/cerbos/query-plan-adapters/tree/main/elasticsearch-java). 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](https://cerbos.dev/slack).  

## Closing thoughts

The adapter is one piece. The policies it translates need somewhere to live, get tested and reach your services.

Try [Cerbos Hub](https://hub.cerbos.cloud/) to version and distribute the policies behind these query plans, or [book a session](https://www.cerbos.dev/workshop) to talk through your Convex schema with our engineers.

Go deeper: [How to adopt externalized authorization](https://solutions.cerbos.dev/how-to-adopt-externalized-authorization) (eBook) for a structured, in-depth approach to navigating the externalized authorization transformation.

## FAQ

### How do you apply Cerbos authorization policies to an Elasticsearch query?

You apply Cerbos policies to Elasticsearch by calling PlanResources, then passing the result and a field map to ElasticsearchQueryPlanAdapter.toElasticsearchQuery. The adapter walks the expression tree and returns a map that serializes to Elasticsearch Query DSL. Place it in a bool.filter clause so the cluster applies authorization using its indexes, without the filter affecting relevance scoring.

### Can Cerbos query plans filter nested objects in Elasticsearch?

Yes. Pass the field names that use Elasticsearch's nested mapping type to the adapter and it translates Cerbos collection operators into nested queries. An exists condition becomes a nested query, and all uses a double negation, meaning no nested document fails the condition, which Elasticsearch evaluates efficiently.

### Do I need Cerbos Hub to use the Elasticsearch query plan adapter?

No, the adapter calls a Cerbos policy decision point and works without Cerbos Hub. Hub is what handles versioning, testing every policy change before it rolls out, distributing bundles to every decision point without a restart, and retaining decision logs for audit. The adapter itself needs only cerbos-sdk-java and protobuf-java.
