<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Coffee Logs]]></title><description><![CDATA[Coffee Logs]]></description><link>https://blog.arunbalachandran.com</link><generator>RSS for Node</generator><lastBuildDate>Fri, 17 Apr 2026 10:05:01 GMT</lastBuildDate><atom:link href="https://blog.arunbalachandran.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Natural Language Queries on Postgres using CQRS (Elasticsearch) & Spring AI]]></title><description><![CDATA[Overview
Interacting with data through natural language is quickly becoming a mainstream expectation in modern applications. Whether it's asking for "all orders over $50" or "products added last week", enabling users to query systems in natural langu...]]></description><link>https://blog.arunbalachandran.com/natural-language-queries-on-postgres-using-cqrs-elasticsearch-and-spring-ai</link><guid isPermaLink="true">https://blog.arunbalachandran.com/natural-language-queries-on-postgres-using-cqrs-elasticsearch-and-spring-ai</guid><category><![CDATA[Java]]></category><category><![CDATA[elasticsearch]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Springboot]]></category><category><![CDATA[AI]]></category><dc:creator><![CDATA[Arun Balchandran]]></dc:creator><pubDate>Mon, 02 Jun 2025 03:57:57 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1748836162464/7fe4bf98-f22a-49f6-9f1d-4ea05e7368a6.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-overview">Overview</h1>
<p>Interacting with data through natural language is quickly becoming a mainstream expectation in modern applications. Whether it's asking for "all orders over $50" or "products added last week", enabling users to query systems in natural language can dramatically improve accessibility and productivity of users.</p>
<hr />
<h1 id="heading-motivation">Motivation</h1>
<h2 id="heading-why-elasticsearch">Why Elasticsearch?</h2>
<p>Relational databases like <strong>PostgreSQL</strong> are great for storing structured, transactional data. However, when it comes to searching large datasets with flexible, full-text queries, they can be limiting and complicated. To solve this pattern, we can use the CQRS pattern.</p>
<h3 id="heading-cqrs">CQRS</h3>
<p><strong>CQRS (Command Query Responsibility Segregation)</strong> is a design pattern that separates <strong>read operations (queries)</strong> from <strong>write operations (commands)</strong>. Instead of using the same model to update and read data, you use two distinct models:</p>
<ul>
<li><p><strong>Commands</strong> modify data (like "PlaceOrder" or "UpdateOrder")</p>
</li>
<li><p><strong>Queries</strong> fetch data (like "GetOrderDetails" or "ListOrders")</p>
</li>
</ul>
<p>This separation makes systems more <strong>scalable</strong>, <strong>maintainable</strong>, and can help optimize each side individually. You could implement this various ways, e.g.: single database with a <strong>Command module</strong> &amp; <strong>Query module</strong> interfacing with it in the application side, or in this example, using a relational database (Postgresql) for writes and a fast NoSQL (Elasticsearch) for reads. For more information, you can read <a target="_blank" href="https://martinfowler.com/bliki/CQRS.html">Martin Fowler’s Essay</a> on this topic.</p>
<h3 id="heading-how-does-elasticsearch-fit-the-bill">How does Elasticsearch fit the bill?</h3>
<p><strong>Elasticsearch</strong> complements PostgreSQL by enabling powerful, fast, and fuzzy search capabilities over the same data. By syncing PostgreSQL to Elasticsearch (using kafka connectors), you get the <strong>best of both worlds</strong>: reliable data storage and advanced search. This proves useful for applications like Semantic search, RAG &amp; many other search related use cases.</p>
<h2 id="heading-complexity-of-querying">Complexity of querying</h2>
<p>Traditionally, search in applications is implemented using <strong>static queries</strong>—hardcoded SQL / Query DSL with strict filters or limited parameters (complexity that grows as the users ask to query more fields). But users rarely think in ‘columns’, they want to answers to questions like:</p>
<ul>
<li><p>“Show all orders over $50 from last month”</p>
</li>
<li><p>“Find customers who bought greater than 7 items”</p>
</li>
</ul>
<p>Supporting these kind of <strong>dynamic, user-generated</strong> queries is tough with static scripts or code alone. It either leads to overly complex query builders or brittle pattern matching logic with limited flexibility. Using <strong>Spring AI</strong> with <strong>Elasticsearch</strong> powered by an <strong>LLM (OpenAI)</strong>, we can now interpret &amp; translate the queries it into <strong>dynamically generated DSL</strong> making it easier to query the system, in turn making it much more user-friendly and accessible.</p>
<p>In this post, we will:</p>
<ul>
<li><p>Build an infrastructure pipeline with PostgreSQL, Kafka, and Elasticsearch enabling near real-time searchability</p>
</li>
<li><p>Use Spring AI to handle natural language query interpretation</p>
</li>
<li><p>Run and test the system with real examples</p>
</li>
</ul>
<hr />
<h1 id="heading-setting-up-and-running-the-project">Setting Up and Running the Project</h1>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before getting started, make sure the following tools are installed:</p>
<ul>
<li><p><a target="_blank" href="https://sdkman.io/install/"><strong>Java 21 using SDKMAN</strong></a></p>
</li>
<li><p><a target="_blank" href="https://sdkman.io/install/"><strong>Docker &amp; Docker Compose</strong></a></p>
</li>
<li><p><strong>OpenAI API Key</strong></p>
</li>
<li><p>Your favorite text editor (I’m using <strong>Intellij</strong> for Java &amp; <strong>VSCode</strong> for <strong>React</strong>)</p>
</li>
<li><p><strong>Optional:</strong> If running the UI helper application, you need <strong>Node</strong> (v22.12.0+ preferred)</p>
</li>
</ul>
<p><strong>Using WSL?</strong><br />If you're using WSL like I am, the same steps would work for you as well. Just open your favorite terminal editor (I prefer Powershell), &amp; login to WSL.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746501095883/cc4df870-970d-4f98-8c80-963bd1f0a6f8.png?auto=compress,format&amp;format=webp" alt /></p>
<p>Now you can follow along with the commands described in the post.</p>
<p><strong>Note:</strong> If you have any trouble following any of the steps, you can check the application README.md in <a target="_blank" href="https://github.com/arunbalachandran/QueryElasticUsingSpringAI/blob/main/README.md">Github</a> for troubleshooting steps</p>
<h3 id="heading-1-clone-the-repository-amp-start-infrastructure">1. Clone the Repository &amp; Start Infrastructure</h3>
<p>You can find the code here: https://github.com/arunbalachandran/QueryElasticUsingSpringAI</p>
<pre><code class="lang-bash">git <span class="hljs-built_in">clone</span> https://github.com/arunbalachandran/QueryElasticUsingSpringAI
<span class="hljs-built_in">cd</span> QueryElasticUsingSpringAI
docker compose up -d

[+] Building 0.0s (0/0)
[+] Running 9/9
 ✔ Network queryelasticusingspringai_dockernet             Create...                                               0.1s
 ✔ Container queryelasticusingspringai-postgres-1          Sta...                                                  0.9s
 ✔ Container queryelasticusingspringai-zookeeper-1         St...                                                   0.8s
 ✔ Container queryelasticusingspringai-elasticsearch-1     Started                                                 0.7s
 ✔ Container queryelasticusingspringai-kibana-1            Start...                                                1.0s
 ✔ Container queryelasticusingspringai-kafka-1             Starte...                                               1.2s
 ✔ Container queryelasticusingspringai-akhq-1              Started                                                 1.6s
 ✔ Container queryelasticusingspringai-debezium-connect-1  Started                                                 1.7s
 ✔ Container queryelasticusingspringai-kafka-connect-1     Started                                                 1.7s
</code></pre>
<h3 id="heading-2-open-querybackend"><strong>2. Open querybackend</strong></h3>
<p>Open the <strong>querybackend</strong> project in your favorite text editor. I’ve used <strong>Intellij</strong>:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1747977182119/c0bf10d1-94c4-4808-a2e2-d364e4f9c809.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-3-set-your-environment-for-java">3. Set Your Environment for Java</h3>
<pre><code class="lang-bash"><span class="hljs-built_in">export</span> OPENAI_API_KEY=your-api-key-here
</code></pre>
<p>Or, if you're using IntelliJ, you can set it in your run configuration.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1747977229774/568ceb1e-3627-48a6-87a5-4db79f54e473.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-4-initialize-elasticsearch-mapping">4. Initialize Elasticsearch Mapping</h3>
<p>Create the <code>order</code> index:</p>
<p><strong>Note:</strong> If using Postman, import the collection included in the repository into Postman &amp; you can follow along using the endpoints mentioned here.</p>
<pre><code class="lang-bash">curl -X PUT <span class="hljs-string">"http://localhost:9200/order"</span> \
-H <span class="hljs-string">"Content-Type: application/json"</span> \
-d <span class="hljs-string">'{
    "mappings": {
    "properties": {
        "id": {
        "type": "keyword"
        },
        "product_name": {
        "type": "text",
        "fields": {
            "keyword": {
            "type": "keyword",
            "ignore_above": 256
            }
        }
        },
        "product_qty": {
        "type": "integer"
        },
        "product_price": {
        "type": "double"
        },
        "product_description": {
        "type": "text"
        },
        "created_time": {
        "type": "date"
        },
        "updated_time": {
        "type": "date"
        }
    }
    }
}'</span>
</code></pre>
<h3 id="heading-5-start-the-application">5. Start the Application</h3>
<pre><code class="lang-java">./gradlew bootRun
</code></pre>
<p>Or, if using Intellij, run the ‘<strong>bootRun’</strong> gradle task</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1747977767914/21e6c2b5-ba26-4204-b8fb-b79f04a07789.png" alt class="image--center mx-auto" /></p>
<hr />
<h2 id="heading-data-flow-postgresql-kafka-elasticsearch">Data Flow: PostgreSQL ➝ Kafka ➝ Elasticsearch</h2>
<p>To get data flowing from your relational DB to Elasticsearch:</p>
<h4 id="heading-1-set-up-kafka-connectors">1. Set Up Kafka Connectors</h4>
<pre><code class="lang-bash"><span class="hljs-comment"># PostgreSQL to Kafka (Debezium)</span>
curl -X POST http://localhost:8084/connectors -H <span class="hljs-string">"Content-Type: application/json"</span> -d <span class="hljs-string">'{
    "name": "postgres-to-kafka-connector",
    "config": {
        "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
        "database.hostname": "postgres",
        "database.port": "5432",
        "database.user": "postgres",
        "database.password": "password",
        "database.dbname" : "querybackend",
        "topic.prefix": "connector",
        "tasks.max": "1",
        "schemas.enable": "false",
        "schema.include.list": "public",
        "table.include.list": "public.orders",
        "signal.data.collection": "public.debezium_signal",
        "key.converter": "org.apache.kafka.connect.json.JsonConverter",
        "key.converter.schemas.enable": false,
        "value.converter": "org.apache.kafka.connect.json.JsonConverter",
        "value.converter.schemas.enable": false,
        "auto.register.schemas": true,
        "topic.creation.default.replication.factor": 1,
        "topic.creation.default.partitions": 1,
        "transforms": "extractlatest",
        "transforms.extractlatest.type": "org.apache.kafka.connect.transforms.ExtractField$Value",
        "transforms.extractlatest.field": "after",
        "time.precision.mode": "connect",
        "decimal.handling.mode": "double",
        "heartbeat.interval.ms": "1800000",
        "snapshot.mode": "initial",
        "plugin.name": "pgoutput",
        "slot.name" : "query_slot_orders_01"
    }
}'</span>

<span class="hljs-comment"># Kafka to Elasticsearch (Confluent Sink)</span>
curl -X POST http://localhost:8084/connectors -H <span class="hljs-string">"Content-Type: application/json"</span> -d <span class="hljs-string">'{
  "name": "elasticsearch-sink-connector",
  "config": {
    "connector.class": "io.confluent.connect.elasticsearch.ElasticsearchSinkConnector",
    "tasks.max": "1",
    "topics": "connector.public.orders",
    "schemas.enable": false,
    "schema.ignore": true,
    "key.converter": "org.apache.kafka.connect.json.JsonConverter",
    "key.converter.schemas.enable": false,    
    "value.converter": "org.apache.kafka.connect.json.JsonConverter",
    "value.converter.schemas.enable": false,
    "type.name": "_doc",
    "key.ignore": false,
    "index": "orders",
    "connection.url": "http://elasticsearch:9200",
    "transforms": "InsertKey,ExtractId",
    "transforms.InsertKey.type": "org.apache.kafka.connect.transforms.ValueToKey",
    "transforms.InsertKey.fields": "id",
    "transforms.ExtractId.type": "org.apache.kafka.connect.transforms.ExtractField$Key",
    "transforms.ExtractId.field": "id",
    "transforms.unwrap.drop.tombstones": "false",
    "transforms.unwrap.drop.deletes": "false",
    "behavior.on.null.values": "delete"
  }
}'</span>
</code></pre>
<hr />
<h1 id="heading-querying-with-natural-language">Querying with Natural Language</h1>
<p>Once everything is up and running, it's time to test the actual use case: converting plain English queries into Elasticsearch queries using Spring AI.</p>
<h2 id="heading-testing-the-backend-api">Testing the Backend API</h2>
<h3 id="heading-add-test-data">Add Test Data</h3>
<pre><code class="lang-bash">curl -X POST http://localhost:8080/api/v1/orders -H <span class="hljs-string">"Content-Type: application/json"</span> -d <span class="hljs-string">'{
  "productName": "Peaches",
  "productQty": 8,
  "productPrice": 12,
  "productDescription": "Peaches"
}'</span>
</code></pre>
<h3 id="heading-try-a-natural-language-query">Try a Natural Language Query</h3>
<p>The moment of truth! We will harness the power of OpenAI’s API to parse our queries &amp; fetch the data from our database.</p>
<p><strong>Note:</strong> I’ve already added a few sample records in the database.</p>
<pre><code class="lang-java">curl -X POST http:<span class="hljs-comment">//localhost:8080/api/v1/elastic/query \</span>
-H <span class="hljs-string">"Content-Type: application/json"</span> \
-d <span class="hljs-string">'{
    "query": "Get me all the orders with quantity greater than 5"
}'</span>

# Response
[
    {
        <span class="hljs-string">"id"</span>: <span class="hljs-string">"bfacba9b-66bb-480f-b63c-eeb1f0381a21"</span>,
        <span class="hljs-string">"productName"</span>: <span class="hljs-string">"Orange"</span>,
        <span class="hljs-string">"productQty"</span>: <span class="hljs-number">6</span>,
        <span class="hljs-string">"productPrice"</span>: <span class="hljs-number">10.0</span>,
        <span class="hljs-string">"productDescription"</span>: <span class="hljs-string">"California Oranges"</span>,
        <span class="hljs-string">"createdTime"</span>: <span class="hljs-string">"2025-05-20T07:52:59.54"</span>,
        <span class="hljs-string">"updatedTime"</span>: <span class="hljs-string">"2025-05-20T07:52:59.541"</span>
    },
    {
        <span class="hljs-string">"id"</span>: <span class="hljs-string">"4c3e65ec-8684-480a-a9b0-50925209146d"</span>,
        <span class="hljs-string">"productName"</span>: <span class="hljs-string">"Mangoes"</span>,
        <span class="hljs-string">"productQty"</span>: <span class="hljs-number">12</span>,
        <span class="hljs-string">"productPrice"</span>: <span class="hljs-number">15.0</span>,
        <span class="hljs-string">"productDescription"</span>: <span class="hljs-string">"Alphonso Mangoes"</span>,
        <span class="hljs-string">"createdTime"</span>: <span class="hljs-string">"2025-05-20T07:53:30.606"</span>,
        <span class="hljs-string">"updatedTime"</span>: <span class="hljs-string">"2025-05-20T07:53:30.606"</span>
    },
    {
        <span class="hljs-string">"id"</span>: <span class="hljs-string">"7f1838cf-577a-42bd-b4a2-d58d3a70cedd"</span>,
        <span class="hljs-string">"productName"</span>: <span class="hljs-string">"Apple"</span>,
        <span class="hljs-string">"productQty"</span>: <span class="hljs-number">8</span>,
        <span class="hljs-string">"productPrice"</span>: <span class="hljs-number">10.0</span>,
        <span class="hljs-string">"productDescription"</span>: <span class="hljs-string">"Apples"</span>,
        <span class="hljs-string">"createdTime"</span>: <span class="hljs-string">"2025-05-20T07:52:46.101"</span>,
        <span class="hljs-string">"updatedTime"</span>: <span class="hljs-string">"2025-05-20T07:52:46.101"</span>
    },
    {
        <span class="hljs-string">"id"</span>: <span class="hljs-string">"6fa9a0f8-b2c8-4c1c-86a5-b5344d2f2a58"</span>,
        <span class="hljs-string">"productName"</span>: <span class="hljs-string">"Peaches"</span>,
        <span class="hljs-string">"productQty"</span>: <span class="hljs-number">7</span>,
        <span class="hljs-string">"productPrice"</span>: <span class="hljs-number">12.0</span>,
        <span class="hljs-string">"productDescription"</span>: <span class="hljs-string">"Peaches"</span>,
        <span class="hljs-string">"createdTime"</span>: <span class="hljs-string">"2025-05-20T07:54:01.823"</span>,
        <span class="hljs-string">"updatedTime"</span>: <span class="hljs-string">"2025-05-20T07:54:01.823"</span>
    }
]
</code></pre>
<p>Behind the scenes, Spring AI uses the OpenAI API to interpret the query and translate it into a DSL that Elasticsearch understands. The system is designed to be extensible, so you can add more context, prompt templates, or user data as needed.</p>
<hr />
<h1 id="heading-seeing-it-in-action">Seeing it in action</h1>
<p>I’ve included a sample React based UI app, that we can use to see the API in action in the context of a Real World Application</p>
<h3 id="heading-run-ui-code">Run UI Code</h3>
<p>Navigate to the <strong>queryui</strong> folder &amp; run the following commands:</p>
<pre><code class="lang-bash"><span class="hljs-built_in">cd</span> queryui
<span class="hljs-comment"># install dependencies if you haven't already</span>
npm install
<span class="hljs-comment"># run the application</span>
npm run dev
<span class="hljs-comment"># Application starts on port 5173</span>
</code></pre>
<p>Navigate to the application on http://localhost:5173 &amp; you should see a UI that looks like this:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748820183748/a2521803-5855-4d84-a7cf-741ed365e9be.png" alt class="image--center mx-auto" /></p>
<p>Run a query &amp; you should see the results update in the screen:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748820025361/6b72415d-52ca-4e11-830c-26c5e6f06617.png" alt class="image--center mx-auto" /></p>
<hr />
<h1 id="heading-under-the-hood">Under the Hood</h1>
<h2 id="heading-the-connector">The connector</h2>
<ul>
<li><p>There are 2 connectors at play here. The Debezium connector &amp; the Confluent connector.</p>
</li>
<li><p>The debezium connector is responsible for relaying the data from Postgres to Kafka</p>
</li>
<li><p>While, the confluent connector is responsible for relaying the data from Kafka to Elasticsearch.</p>
</li>
<li><p>💡Note: It may be possible to use Debezium to stream the data both as the source &amp; the sink connectors, but is a bit more cumbersome to setup. This is left as an exercise to the reader.</p>
</li>
</ul>
<h2 id="heading-spring-ai-integration">Spring AI integration</h2>
<p>Let’s take a closer look at the <code>PromptService</code> class. This is where the magic happens. It’s responsible for turning user-written natural language queries into structured Elasticsearch queries with the help of Spring AI, which provides us a wrapper to the Open AI Large Language Model (LLM).</p>
<h3 id="heading-purpose-of-the-service">Purpose of the Service</h3>
<p>The <code>PromptService</code> serves as the <strong>bridge between user intent and machine-readable queries</strong>. When a user enters a question like <em>“Show me all orders above $50 from last month”</em>, this service constructs a prompt and uses an LLM to generate a precise Elasticsearch query based on the structure of the index.</p>
<hr />
<h3 id="heading-key-components">Key Components</h3>
<h4 id="heading-chatmodel">ChatModel</h4>
<ul>
<li><p>Injects the LLM backend provided by Spring AI. Here configured to use OpenAI (<code>gpt-4o</code>).</p>
</li>
<li><p>This is the model responsible for converting natural language into JSON-based Elasticsearch queries.</p>
</li>
</ul>
<h4 id="heading-elasticsearchservice">ElasticSearchService</h4>
<ul>
<li>A helper service to interact with the Elasticsearch index, get mappings, and perform searches.</li>
</ul>
<hr />
<h3 id="heading-prompt-initialization-postconstruct">Prompt Initialization (<code>@PostConstruct</code>)</h3>
<pre><code class="lang-java"><span class="hljs-meta">@PostConstruct</span>
<span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title">init</span><span class="hljs-params">()</span> </span>{
    String mapping = elasticsearchService.getOrderMapping();
    <span class="hljs-keyword">this</span>.basePrompt = <span class="hljs-string">""</span><span class="hljs-string">"
        I need you to convert natural language user queries into elasticsearch queries...
        ...
        "</span><span class="hljs-string">""</span>.formatted(mapping);
}
</code></pre>
<ul>
<li><p>On application startup, we retrieve the <strong>Elasticsearch mapping</strong> for the <code>Order</code> index.</p>
</li>
<li><p>This mapping is embedded into the <code>basePrompt</code>. It gives the LLM context about what fields exist (e.g., <code>productName</code>, <code>productPrice</code>, <code>createdTime</code>).</p>
</li>
<li><p>The prompt instructs the LLM <strong>how to behave</strong>, e.g., “don't use markdown”, “output the mapping without formatting,” and “understand the semantics of what’s being asked.”</p>
</li>
</ul>
<h3 id="heading-processing-the-user-query">Processing the User Query</h3>
<pre><code class="lang-java"><span class="hljs-function"><span class="hljs-keyword">public</span> List&lt;OrderDTO&gt; <span class="hljs-title">processPrompt</span><span class="hljs-params">(String userQuery)</span> </span>{
    String fullPrompt = basePrompt + <span class="hljs-string">"\nUser query: "</span> + userQuery;
    ...
}
</code></pre>
<ol>
<li><p>The <code>userQuery</code> (e.g., “Get me all the orders with quantity greater than 5”) is appended to the <code>basePrompt</code>.</p>
</li>
<li><p>This becomes a <strong>single, complete prompt</strong> that’s fed to the LLM.</p>
</li>
</ol>
<pre><code class="lang-java"><span class="hljs-function"><span class="hljs-keyword">public</span> List&lt;OrderDTO&gt; <span class="hljs-title">processPrompt</span><span class="hljs-params">(String userQuery)</span> </span>{
    String fullPrompt = basePrompt + <span class="hljs-string">"\nUser query: "</span> + userQuery;
    log.info(<span class="hljs-string">"Prompt being used: {}"</span>, fullPrompt);
    ChatResponse chatResponse = chatModel.call(
            <span class="hljs-keyword">new</span> Prompt(
                    fullPrompt,
                    OpenAiChatOptions.builder()
                            .model(<span class="hljs-string">"gpt-4o"</span>)
                            .temperature(<span class="hljs-number">1.0</span>)
                            .build()
            )
    );
    String elasticQuery = chatResponse.getResult().getOutput().getText();
    log.info(<span class="hljs-string">"Elastic query: {}"</span>, elasticQuery);
    Map&lt;String, Object&gt; response = elasticsearchService.search(elasticQuery);
    <span class="hljs-keyword">return</span> ElasticMapper.mapToOrderDTO(response);
}
</code></pre>
<ol start="3">
<li>The prompt is sent to the LLM (<a target="_blank" href="http://chatModel.call"><code>chatModel.call</code></a><code>()</code>), and we expect a raw JSON Elasticsearch query in return.</li>
</ol>
<pre><code class="lang-java">String elasticQuery = chatResponse.getResult().getOutput().getText();
</code></pre>
<ol start="4">
<li>The LLM's output is extracted as a string. This is the dynamically generated <strong>Elasticsearch DSL query</strong>.</li>
</ol>
<pre><code class="lang-java">Map&lt;String, Object&gt; response = elasticsearchService.search(elasticQuery);
<span class="hljs-keyword">return</span> ElasticMapper.mapToOrderDTO(response);
</code></pre>
<ol start="5">
<li><p>The generated query is executed via <a target="_blank" href="http://elasticsearchService.search"><code>elasticsearchService.search</code></a><code>()</code>.</p>
</li>
<li><p>The raw search results are then mapped to <code>OrderDTO</code> objects for use in the API.</p>
</li>
</ol>
<hr />
<h3 id="heading-why-this-matters">Why This Matters</h3>
<p>This pattern allows your application to <strong>understand and interpret human-friendly input</strong> without rigid UI constraints or static filters. It unlocks a much more intuitive experiences, especially valuable for user-facing search interfaces &amp; dashboards.</p>
<p>It also <strong>abstracts the complexity of search syntax</strong> away from the user while still allowing them to perform advanced, flexible queries based on their intent.</p>
<h2 id="heading-sequence-diagram">Sequence Diagram</h2>
<p>To better illustrate the flow, here’s a sequence diagram, showing the order of events in the application:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1748837305442/e9bf0fe2-d221-4565-933f-27cd99f34fe7.png" alt class="image--center mx-auto" /></p>
<hr />
<h1 id="heading-wrapping-it-up">Wrapping it up</h1>
<p>In this post, I’ve demonstrated how we can:</p>
<ul>
<li><p>Set up a full-stack data ingestion pipeline &amp; search stack using PostgreSQL, Kafka, and Elasticsearch</p>
</li>
<li><p>Use Spring AI and OpenAI's LLMs to handle natural language queries</p>
</li>
<li><p>Automatically translate plain English into structured Elasticsearch queries</p>
</li>
</ul>
<h3 id="heading-future-use-cases">Future Use Cases</h3>
<p>Here are a few ideas for extending this:</p>
<ul>
<li><p>Add user roles and access filters to contextualize results</p>
</li>
<li><p>Enable conversational memory with a chat-style interface allowing users to ask follow up questions</p>
</li>
<li><p>Use RAG (Retrieval-Augmented Generation) for more dynamic query understanding</p>
</li>
<li><p>Integrate Kibana dashboards that go along with the generated results</p>
</li>
</ul>
<hr />
<p>This foundation gives you a practical starting point for building intelligent, search-driven interfaces. If you're exploring ways to make data more accessible, this is a compelling approach worth trying!</p>
<p>Got ideas or questions? Drop them in the comments! 💬</p>
]]></content:encoded></item><item><title><![CDATA[Dynamic Rules for Your App: Getting Started with Open Policy Agent (OPA)]]></title><description><![CDATA[Overview
In today’s modern microservice based applications, policy-based control has become essential for enforcing access rules, managing compliance, and ensuring secure behavior across services. One powerful tool that helps developers build these r...]]></description><link>https://blog.arunbalachandran.com/building-a-policy-engine-using-opa-open-policy-agent</link><guid isPermaLink="true">https://blog.arunbalachandran.com/building-a-policy-engine-using-opa-open-policy-agent</guid><category><![CDATA[Java]]></category><category><![CDATA[Springboot]]></category><category><![CDATA[policy as code]]></category><category><![CDATA[rego]]></category><category><![CDATA[opa]]></category><dc:creator><![CDATA[Arun Balchandran]]></dc:creator><pubDate>Fri, 25 Apr 2025 02:43:24 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1746415541730/70ab01cc-fc85-4b21-b731-f4d571869340.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-overview">Overview</h2>
<p>In today’s modern microservice based applications, <strong>policy-based control</strong> has become essential for enforcing access rules, managing compliance, and ensuring secure behavior across services. One powerful tool that helps developers build these rules declaratively is a framework called <strong>Open Policy Agent (OPA)</strong>.</p>
<p>If you haven’t heard of OPA before, it’s allows you to decouple policy from code, enabling better manageability, auditing, and testing of rules. In this post, we’ll dive into:</p>
<ul>
<li><p>What is OPA, Really?</p>
</li>
<li><p>How to write your first policy with Rego</p>
</li>
<li><p>And how to integrate OPA with a real-world Spring Boot application</p>
</li>
<li><p>How it works under the hood?</p>
</li>
</ul>
<hr />
<h2 id="heading-what-is-opa-really">What is OPA, Really?</h2>
<p><strong>OPA (Open Policy Agent)</strong> is a powerful policy engine and framework that lets you <strong>decouple rules from your application logic</strong>. Instead of hardcoding authorization, filtering, or validation logic, you define these rules in a separate, declarative format using OPA's policy language, <strong>Rego</strong>.</p>
<p>Why does that matter? Because it gives you <strong>flexibility</strong>. You can modify policies on the fly — <strong>no need to restart or redeploy</strong> your application.</p>
<p>💡 Imagine the possibilities:</p>
<ul>
<li><p>Zero-downtime <strong>feature toggles</strong></p>
</li>
<li><p>Real-time adjustments for <strong>system load</strong></p>
</li>
<li><p>Rapid adaptation to <strong>evolving business rules</strong></p>
</li>
</ul>
<p>And that’s just scratching the surface! With OPA, your system becomes more <strong>dynamic, adaptable, and maintainable</strong>.</p>
<h2 id="heading-getting-hands-on-with-opa">Getting Hands-On with OPA</h2>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before diving into OPA + Spring Boot, make sure your local environment is set up with the following:</p>
<ul>
<li><p><strong>Java 17</strong><br />  The project uses Java 17 as the target JDK for compiling and running the Spring Boot application.</p>
</li>
<li><p><strong>Docker</strong><br />  Required for running the OPA, PostgreSQL, and Redis containers.</p>
</li>
</ul>
<p><strong>Setup Resources</strong></p>
<ul>
<li><p><a target="_blank" href="https://sdkman.io/install/">Install Java 17 using SDKMAN</a> (my preferred option) or you can install Java directly using ‘apt’</p>
</li>
<li><p><a target="_blank" href="https://docs.docker.com/get-started/">Install docker &amp; docker compose</a></p>
</li>
</ul>
<p><strong>Using WSL?</strong><br />If you're using WSL like I am, the same steps would work for you as well. Just open your favorite terminal editor (I prefer Powershell), &amp; login to WSL.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746501095883/cc4df870-970d-4f98-8c80-963bd1f0a6f8.png" alt class="image--center mx-auto" /></p>
<p>Now you can follow along with the commands described in the post.</p>
<h3 id="heading-1-writing-your-first-opa-policy">1. Writing Your First OPA Policy</h3>
<p>OPA uses a language called <strong>Rego</strong> for writing policies. Here's a super simple rule that checks if a user is an admin:</p>
<pre><code class="lang-plaintext">package auth            # this defines the namespace that your OPA policy will be part of

default allow = false   # variable used to track our policy result, with a default value

allow {
  input.role == "admin" # this is the check our policy executes &amp; if true, allow will be set to true
}
</code></pre>
<p>Let’s break this policy down, into its different components:</p>
<p><code>package auth</code></p>
<p>Every Rego policy belongs to a <strong>package</strong> — think of it like a namespace.<br />This line says: "All the rules in this file belong to the <code>auth</code> package."<br />You’ll refer to this package name when querying OPA, like:<br /><code>/v1/data/auth</code></p>
<hr />
<h4 id="heading-default-allow-false"><code>default allow = false</code></h4>
<p>This sets the <strong>default value</strong> for the <code>allow</code> rule to <code>false</code> (i.e., deny by default).<br />If no other rule matches, OPA will return <code>false</code>.<br /><strong>Secure by default</strong> — always a good policy principle.</p>
<hr />
<h4 id="heading-allow-inputrole-admin"><code>allow { input.role == "admin" }</code></h4>
<p>This is the <strong>rule logic</strong>. It says:</p>
<blockquote>
<p>“Allow access <em>only</em> if the input role is <code>admin</code>.”</p>
</blockquote>
<p>OPA evaluates the input JSON sent by your application — for example:</p>
<pre><code class="lang-yaml">{
  <span class="hljs-attr">"role":</span> <span class="hljs-string">"admin"</span>
}
</code></pre>
<p>If the condition matches, <code>allow</code> becomes <code>true</code>, and access is granted.</p>
<hr />
<p>TL;DR: This policy enforces <strong>admin-only access</strong>, is easy to tweak, with just a few lines of Rego.</p>
<h3 id="heading-2-playing-around-in-the-rego-playground">2. Playing Around in the Rego Playground</h3>
<p>The <a target="_blank" href="https://play.openpolicyagent.org/">OPA Playground</a> is a web-based tool where you can:</p>
<ul>
<li><p>Write and test Rego policies</p>
</li>
<li><p>Provide input JSON</p>
</li>
<li><p>See decision outputs in real-time</p>
</li>
</ul>
<p>Here’s how the Rego Playground looks like:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1745843373974/8a277e51-2386-46f3-9da3-aed338f5f51b.png" alt class="image--center mx-auto" /></p>
<p>You can use the section on the left to add your policy and the section on the right to add inputs for the policy.<br />Let’s start by taking the previously discussed example &amp; testing that policy on the OPA playground. Copy the given example policy and it’s inputs to the page. Click the <strong>Evaluate</strong> button to run the evaluate the inputs against the policy.</p>
<p>Sample policy:</p>
<pre><code class="lang-rego"><span class="hljs-keyword">package</span> auth

<span class="hljs-keyword">default</span> allow = <span class="hljs-variable">false</span>

allow <span class="hljs-punctuation">{
</span>  <span class="hljs-variable">input</span>.role == <span class="hljs-string">"admin"</span>
<span class="hljs-punctuation">}</span>
</code></pre>
<p>Example input:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"role"</span>: <span class="hljs-string">"admin"</span>
}
</code></pre>
<p>Example output:</p>
<pre><code class="lang-json">{
    <span class="hljs-attr">"allow"</span>: <span class="hljs-literal">true</span>
}
</code></pre>
<p>The policy sends a response with ‘<strong>allow</strong>’ as ‘<strong>true</strong>’ if the user has a <strong>role</strong> : ‘<strong>admin</strong>’, else it will return ‘<strong>false</strong>’.</p>
<p>Now we can build on this to create a simple end to end application.</p>
<p>💡 Tip: Experiment with different values for the role &amp; check the outputs returned. Also, what if you changed ‘allow’ to be called a different name? How does the output change?</p>
<h3 id="heading-3-integrating-opa-in-a-spring-boot-application">3. Integrating OPA in a Spring Boot Application</h3>
<p>Let’s implement a simple <strong>policy-based authorization</strong> mechanism in a Spring Boot app.<br />For this, I’ve already implemented a simple real world application that has a login page &amp; a simple GET Users endpoint that fetches all the users in the system.</p>
<p>Here’s the sample application that we will be using today: <a target="_blank" href="https://github.com/arunbalachandran/OpaPolicySpringBoot">https://github.com/arunbalachandran/OpaPolicySpringBoot</a></p>
<p>Clone the application &amp; navigate to the folder using your favorite terminal.<br />If you’re running Windows like me - you can clone this inside WSL.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746380267362/915e2b85-2f5a-41b0-a99e-1e66f70887f0.png" alt class="image--center mx-auto" /></p>
<h3 id="heading-step-1-understand-the-architecture">Step 1: Understand the Architecture</h3>
<p>Before we dive in, here’s a quick overview of the key layers in the application:</p>
<h4 id="heading-core-components">Core Components</h4>
<ul>
<li><p><strong>Security Layer</strong> 🔐<br />  Handles JWT parsing, Spring Security configuration, and exception handling.</p>
<ul>
<li><code>JWTAuthenticationFilter</code>, <code>JWTService</code>, <code>SecurityConfiguration</code></li>
</ul>
</li>
<li><p><strong>Authorization Layer</strong> ✅<br />  Intercepts API requests and checks them with OPA before allowing access.</p>
<ul>
<li><code>OpaService</code>, <code>CustomAuthorizationManager</code>, <code>MethodSecurityConfig</code></li>
</ul>
</li>
<li><p><strong>Data Layer</strong> 🗄️<br />  Backed by PostgreSQL (user data) and Redis (caching/session).</p>
<ul>
<li>JPA Repositories for persistence</li>
</ul>
</li>
<li><p><strong>API Layer</strong> 🌐<br />  Controllers exposing endpoints for authentication and user management.</p>
<ul>
<li>Annotated with <code>@PreAuthorize</code> to enforce OPA checks</li>
</ul>
</li>
</ul>
<hr />
<h3 id="heading-step-2-bring-up-the-system-with-docker-compose-amp-setup-the-database">Step 2: Bring Up the System with Docker Compose &amp; Setup the Database</h3>
<p>Let’s bring up the containers:</p>
<pre><code class="lang-bash">arunbala@ArunRazer:~/OpaPolicySpringBoot$ docker compose up -d
[+] Building 0.0s (0/0)
[+] Running 4/4
 ✔ Network opapolicyspringboot_default       Created                                                               0.2s
 ✔ Container opapolicyspringboot-redis-1     Started                                                               1.1s
 ✔ Container opapolicyspringboot-opa-1       Started                                                               1.2s
 ✔ Container opapolicyspringboot-postgres-1  Started                                                               1.2s
arunbala@ArunRazer:~/OpaPolicySpringBoot$ docker ps
CONTAINER ID   IMAGE                        COMMAND                  CREATED         STATUS         PORTS                                         NAMES
e988e2324495   redis:latest                 <span class="hljs-string">"docker-entrypoint.s…"</span>   7 seconds ago   Up 6 seconds   0.0.0.0:6379-&gt;6379/tcp, [::]:6379-&gt;6379/tcp   opapolicyspringboot-redis-1
775d30d9fdbf   postgres:14.17               <span class="hljs-string">"docker-entrypoint.s…"</span>   7 seconds ago   Up 6 seconds   0.0.0.0:5432-&gt;5432/tcp, [::]:5432-&gt;5432/tcp   opapolicyspringboot-postgres-1
82eec0a4c6ad   openpolicyagent/opa:latest   <span class="hljs-string">"/opa run --server -…"</span>   7 seconds ago   Up 6 seconds   0.0.0.0:8181-&gt;8181/tcp, [::]:8181-&gt;8181/tcp   opapolicyspringboot-opa-1
</code></pre>
<p><strong>What’s Running?</strong></p>
<p>If you look at the docker-compose file, these are the services running underneath the hood:</p>
<ul>
<li><p><code>opa</code>: The Open Policy Agent server on port <code>8181</code> — responsible for evaluating policy decisions.</p>
</li>
<li><p><code>postgres</code>: Stores user data for the app.</p>
</li>
<li><p><code>redis</code>: Handles token-related caching and quick lookups.</p>
</li>
</ul>
<blockquote>
<p>The Spring Boot app will connect to these services using configurations defined in <code>application.yml</code>.</p>
</blockquote>
<p>You can also connect to the database using any database tool of your choice. I prefer using <a target="_blank" href="https://dbeaver.io/">DBeaver</a>.</p>
<hr />
<h3 id="heading-step-3-open-the-project-in-intellij">Step 3: Open the Project in Intellij</h3>
<p>Once the Docker containers are up and running, open the project in IntelliJ IDEA.</p>
<p>Make sure you do the following configuration:</p>
<h3 id="heading-configure-gradle-jvm-to-java-17">Configure Gradle JVM to Java 17</h3>
<ol>
<li><p>Go to <code>File &gt; Project Structure</code></p>
</li>
<li><p>Under <strong>Project SDK</strong>, select <code>Java 17</code></p>
</li>
<li><p>Then, go to <code>Settings &gt; Build, Execution, Deployment &gt; Build Tools &gt; Gradle</code></p>
</li>
<li><p>Set <strong>Gradle JVM</strong> to the same Java 17 SDK</p>
</li>
</ol>
<p><strong>Note:</strong> If you don't set Java 17, you may run into class incompatibility or build failures (e.g., <code>Unsupported class file major version 61</code>).</p>
<p>📸 <em>IntelliJ Gradle settings with Java 17</em></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746383559188/d5df7613-db5c-4914-b0d3-2844c82d3589.png" alt class="image--center mx-auto" /></p>
<p>Once this is set up, you can run the Spring Boot app using the Gradle Task</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746383644723/e9a77010-bd13-4a5b-81a2-4c45e98cd602.png" alt class="image--center mx-auto" /></p>
<p>Or if using the terminal:</p>
<pre><code class="lang-bash">./gradlew bootRun
</code></pre>
<p>The application should start on <a target="_blank" href="http://localhost:8080"><code>http://localhost:8080</code></a>.</p>
<h3 id="heading-step-3-upload-your-first-policy-to-opa">Step 3: Upload Your First Policy to OPA</h3>
<p>We’ll upload a simple policy where <strong>only users with the</strong> <code>ADMIN</code> role are allowed using the ‘<strong>Create a new Policy</strong>’ API</p>
<p><strong>Note:</strong> If using Postman, import the collection included in the repository into Postman &amp; you can follow along using the endpoints mentioned here.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Create a new policy</span>
curl --location --request PUT <span class="hljs-string">'http://localhost:8181/v1/policies/auth'</span> \
--header <span class="hljs-string">'Content-Type: text/plain'</span> \
--data <span class="hljs-string">'package auth

default allow = false

allow if {
  input.role == "ADMIN"
}'</span>

<span class="hljs-comment"># output</span>
200 OK
</code></pre>
<p>You should get ‘<strong>200 OK</strong>’ response that indicates that the policy was uploaded successfully.</p>
<p><strong>Note:</strong> This policy will be queried inside your app via the <code>OpaService</code>, which sends input (e.g., user role) to OPA and receives an allow/deny response.</p>
<hr />
<h3 id="heading-step-4-verify-the-policy-with-a-sample-input">Step 4: Verify the Policy with a Sample Input</h3>
<p>Run the ‘<strong>Evaluate auth’</strong> API to verify that the uploaded policy works by passing in a sample input:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Evaluate auth</span>
curl --location --request POST <span class="hljs-string">'http://localhost:8181/v1/data/auth/allow'</span> \
--header <span class="hljs-string">'Content-Type: application/json'</span> \
--data-raw <span class="hljs-string">'{
  "input": {
    "role": "REG_USER"
  }
}'</span>

<span class="hljs-comment"># output</span>
{
    <span class="hljs-string">"result"</span>: {
        <span class="hljs-string">"allow"</span>: <span class="hljs-literal">true</span>
    }
}
</code></pre>
<p>This is what happens internally when you hit a protected endpoint — the user’s role is passed to OPA via <code>OpaService</code>.</p>
<hr />
<h3 id="heading-step-5-register-a-new-user">Step 5: Register a New User</h3>
<p>Let’s create a new user through the <code>/signup</code> endpoint:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Signup</span>
curl --location <span class="hljs-string">'http://localhost:8080/api/v1/signup'</span> \
--header <span class="hljs-string">'Content-Type: application/json'</span> \
--data-raw <span class="hljs-string">'{
  "name": "Arun",
  "email": "arun@test.com",
  "password": "test1234"
}'</span>

<span class="hljs-comment"># output</span>
{
    <span class="hljs-string">"id"</span>: <span class="hljs-string">"273aef4d-7f95-44d6-be4b-09788c8cdc71"</span>,
    <span class="hljs-string">"name"</span>: <span class="hljs-string">"Arun"</span>,
    <span class="hljs-string">"email"</span>: <span class="hljs-string">"arun@test.com"</span>,
    <span class="hljs-string">"role"</span>: <span class="hljs-string">"REG_USER"</span>
}
</code></pre>
<p>The user is stored in PostgreSQL with the default role: <code>REG_USER</code>.</p>
<p>📸 <em>User with REG_USER role in the database:</em></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746384289638/62fd91b9-b6ad-4d68-a59c-292d8ba0cefe.png" alt class="image--center mx-auto" /></p>
<hr />
<h3 id="heading-step-6-log-in-and-get-tokens">Step 6: Log In and Get Tokens</h3>
<p>Authenticate with the new user to get JWT tokens:</p>
<pre><code class="lang-bash">curl --location <span class="hljs-string">'http://localhost:8080/api/v1/auth/login'</span> --header <span class="hljs-string">'Content-Type: application/json'</span> --data-raw <span class="hljs-string">'{
    "email": "arun@test.com",
    "password": "test1234"
}'</span> -v
<span class="hljs-comment"># output body</span>
{
    <span class="hljs-string">"id"</span>: <span class="hljs-string">"c1340964-029a-4a08-aa4b-b08c786b59e8"</span>,
    <span class="hljs-string">"name"</span>: <span class="hljs-string">"Arun"</span>,
    <span class="hljs-string">"email"</span>: <span class="hljs-string">"arun@test.com"</span>,
    <span class="hljs-string">"role"</span>: <span class="hljs-string">"REG_USER"</span>
}

<span class="hljs-comment"># output headers</span>
{
  ...
  <span class="hljs-string">"access_token"</span>: <span class="hljs-string">"eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjpbIlJFR19VU0VSIl0sInN1YiI6ImFydW5AdGVzdC5jb20iLCJpYXQiOjE3NDYzODQzMzYsImV4cCI6MTc0NjM4NDQ1Nn0.8xL0BQO3HJ5E0US4LqnZach3mRrAVADeluIF4ZXp0IM"</span>,
  <span class="hljs-string">"refresh_token"</span>: <span class="hljs-string">"eyJhbGciOiJIUzI1NiJ9.eyJyb2xlIjpbIlJFR19VU0VSIl0sInN1YiI6ImFydW5AdGVzdC5jb20iLCJpYXQiOjE3NDYzODQzMzYsImV4cCI6MTc0Njk4OTEzNn0.wW_9MH6Dtw2j3pHUTYuBhgQJLdAfcjrn1zO8s0N-rtA"</span>
  ...
}
</code></pre>
<p>The <code>JWTAuthenticationFilter</code> parses this token and sets the user's identity in the security context.</p>
<p>Copy the ‘<strong>access_token</strong>’ from this step in the headers section as we will use it for the next section. This token will be used for the authorization checks.</p>
<hr />
<h3 id="heading-step-7-try-accessing-a-protected-endpoint">Step 7: Try Accessing a Protected Endpoint</h3>
<p>Now try hitting <code>/api/v1/users</code> with your access token:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746384619933/b1008bad-9432-4a88-916d-15a8d14123de.png" alt class="image--center mx-auto" /></p>
<pre><code class="lang-bash">curl --location <span class="hljs-string">'http://localhost:8080/api/v1/users'</span> \
--header <span class="hljs-string">'Authorization: Bearer &lt;your_access_token&gt;'</span>
</code></pre>
<p>Because you're a <code>REG_USER</code>, and our policy only allows <code>ADMIN</code>, you’ll get an error like:</p>
<pre><code class="lang-yaml">{
  <span class="hljs-attr">"message":</span> <span class="hljs-string">"Access Denied"</span>
}
</code></pre>
<p><strong>Note:</strong> The <code>CustomAuthorizationManager</code> kicks in here and uses <code>OpaService</code> to verify authorization before continuing to the service method called by the controller.</p>
<hr />
<h3 id="heading-step-8-update-the-policy-to-allow-reguser">Step 8: Update the Policy to Allow REG_USER</h3>
<p>Let’s update the policy on-the-fly to allow users with the <code>REG_USER</code> role:</p>
<pre><code class="lang-bash">curl --location --request PUT <span class="hljs-string">'http://localhost:8181/v1/policies/auth'</span> \
--header <span class="hljs-string">'Content-Type: text/plain'</span> \
--data <span class="hljs-string">'package auth

default allow = false

allow if {
  input.role == "REG_USER"
}'</span>
</code></pre>
<p>No need to restart the app — OPA will now respond with <code>true</code> for <code>REG_USER</code>!</p>
<hr />
<h3 id="heading-step-9-retry-the-users-endpoint">Step 9: Retry the <code>/users</code> Endpoint</h3>
<p>Use your same access token again:</p>
<pre><code class="lang-bash">curl --location <span class="hljs-string">'http://localhost:8080/api/v1/users'</span> \
--header <span class="hljs-string">'Authorization: Bearer &lt;your_access_token&gt;'</span>

<span class="hljs-comment"># output</span>
[
    {
        <span class="hljs-string">"id"</span>: <span class="hljs-string">"c1340964-029a-4a08-aa4b-b08c786b59e8"</span>,
        <span class="hljs-string">"name"</span>: <span class="hljs-string">"Arun"</span>,
        <span class="hljs-string">"email"</span>: <span class="hljs-string">"arun@test.com"</span>,
        <span class="hljs-string">"role"</span>: <span class="hljs-string">"REG_USER"</span>
    }
]
</code></pre>
<p>Now you should get a list of users!</p>
<p>📸 <em>Postman screenshot:</em></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1746384910101/7252b7d5-255a-4c8f-89aa-5d1e05efa4a0.png" alt class="image--center mx-auto" /></p>
<hr />
<h2 id="heading-under-the-hood-custom-method-level-authorization-with-opa">Under the Hood: Custom Method-Level Authorization with OPA</h2>
<p>In our application, we've implemented fine-grained, <strong>OPA-backed authorization at the method level</strong> using custom Spring Security expressions. Let’s break down the two key players:</p>
<hr />
<h3 id="heading-custommethodsecurityexpressionroot">CustomMethodSecurityExpressionRoot</h3>
<pre><code class="lang-java"><span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CustomMethodSecurityExpressionRoot</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">SecurityExpressionRoot</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">MethodSecurityExpressionOperations</span> </span>{
    ...
    ...

    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">boolean</span> <span class="hljs-title">hasAuthorization</span><span class="hljs-params">(String authToken)</span> </span>{
        <span class="hljs-keyword">return</span> opaService.checkPermission(authToken);
    }
}
</code></pre>
<p>This class is where <strong>custom logic for method security</strong> is injected. Specifically, it defines the method <code>hasAuthorization</code>, which is used in <code>@PreAuthorize</code> annotations like:</p>
<pre><code class="lang-java"><span class="hljs-meta">@PreAuthorize("hasAuthorization(#authToken)")</span>
<span class="hljs-meta">@GetMapping(produces = MediaType.APPLICATION_JSON_VALUE)</span>
<span class="hljs-keyword">public</span> ResponseEntity&lt;List&lt;UserDTO&gt;&gt; findAll(
        <span class="hljs-meta">@RequestHeader(Constants.AUTHORIZATION_HEADER)</span> String authToken
) {
    List&lt;User&gt; users = userService.findAll();
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> ResponseEntity&lt;&gt;(users.stream().map(UserDTO::mapToDto).toList(), HttpStatus.OK);
}
</code></pre>
<p><strong>What this does:</strong></p>
<ul>
<li><p><code>hasAuthorization(...)</code> receives the bearer token from the incoming request.</p>
</li>
<li><p>It <strong>delegates the actual permission check</strong> to the <code>OpaService</code>, which calls OPA to make a decision.</p>
</li>
</ul>
<p>So this is your entry point into the OPA ecosystem for each protected endpoint.</p>
<hr />
<h3 id="heading-opaservice-the-policy-decision-interface">OpaService: The Policy Decision Interface</h3>
<pre><code class="lang-java"><span class="hljs-meta">@Service</span>
<span class="hljs-meta">@Slf4j</span>
<span class="hljs-keyword">public</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OpaService</span> </span>{
    ...
    <span class="hljs-function"><span class="hljs-keyword">public</span> <span class="hljs-keyword">boolean</span> <span class="hljs-title">checkPermission</span><span class="hljs-params">(String token)</span> </span>{
        HttpHeaders headers = <span class="hljs-keyword">new</span> HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        Map&lt;String, Object&gt; input = <span class="hljs-keyword">new</span> HashMap&lt;&gt;();
        input.put(OPA_INPUT, Map.of(ROLE, jwtService.extractClaim(preParseToken(token), val -&gt; val.get(ROLE, List.class)).get(<span class="hljs-number">0</span>)));
        HttpEntity&lt;Map&lt;String, Object&gt;&gt; request = <span class="hljs-keyword">new</span> HttpEntity&lt;&gt;(input, headers);
        ResponseEntity&lt;Map&gt; response = restTemplate.postForEntity(
            opaUrl + <span class="hljs-string">"/v1/data/auth"</span>,
            request,
            Map.class
        );

        <span class="hljs-keyword">if</span> (response.getStatusCode() == HttpStatus.OK &amp;&amp; response.getBody() != <span class="hljs-keyword">null</span>) {
            <span class="hljs-keyword">return</span> Boolean.TRUE.equals(((Map&lt;String, String&gt;)response.getBody().get(RESULT)).get(ALLOW));
        }
        <span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>;
    }
}
</code></pre>
<p>This service acts as the <strong>communication bridge</strong> between your Spring Boot app and the OPA server.</p>
<p>Let’s walk through what happens inside <code>checkPermission(...)</code> step by step:</p>
<hr />
<h4 id="heading-step-1-clean-the-token">Step 1: Clean the Token</h4>
<pre><code class="lang-java"><span class="hljs-function"><span class="hljs-keyword">private</span> String <span class="hljs-title">preParseToken</span><span class="hljs-params">(String token)</span> </span>{
    <span class="hljs-keyword">return</span> token.replace(BEARER_PREFIX, <span class="hljs-string">""</span>);
}
</code></pre>
<p>Since bearer tokens often look like <code>Bearer &lt;JWT&gt;</code>, this method trims off the <code>"Bearer "</code> prefix so we get the raw JWT string.</p>
<hr />
<h4 id="heading-step-2-extract-the-role-from-jwt">Step 2: Extract the Role from JWT</h4>
<pre><code class="lang-java">jwtService.extractClaim(preParseToken(token), val -&gt; val.get(ROLE, List.class)).get(<span class="hljs-number">0</span>)
</code></pre>
<p>We:</p>
<ul>
<li><p>Decode the JWT using <code>JWTService</code></p>
</li>
<li><p>Pull out the <code>role</code> claim (which might be a list, like <code>["ADMIN"]</code>)</p>
</li>
<li><p>Use only the first role for now (could be extended later)</p>
</li>
</ul>
<p>This extracted role becomes part of the input sent to OPA.</p>
<hr />
<h4 id="heading-step-3-build-the-opa-input-payload">Step 3: Build the OPA Input Payload</h4>
<pre><code class="lang-json">{
  <span class="hljs-attr">"input"</span>: {
    <span class="hljs-attr">"role"</span>: <span class="hljs-string">"ADMIN"</span>
  }
}
</code></pre>
<p>This input is what the OPA policy will evaluate. It’s wrapped in a JSON map and sent with the request.</p>
<hr />
<h4 id="heading-step-4-make-the-opa-call">Step 4: Make the OPA Call</h4>
<pre><code class="lang-java">ResponseEntity&lt;Map&gt; response = restTemplate.postForEntity(
    opaUrl + <span class="hljs-string">"/v1/data/auth"</span>,
    request,
    Map.class
);
</code></pre>
<p>We POST the input to the OPA <code>/v1/data/auth</code> endpoint and wait for a decision.</p>
<hr />
<h4 id="heading-step-5-interpret-the-response">Step 5: Interpret the Response</h4>
<pre><code class="lang-java"><span class="hljs-keyword">return</span> Boolean.TRUE.equals(((Map&lt;String, String&gt;)response.getBody().get(RESULT)).get(ALLOW));
</code></pre>
<p>OPA responds with something like:</p>
<pre><code class="lang-java">{
  <span class="hljs-string">"result"</span>: {
    <span class="hljs-string">"allow"</span>: <span class="hljs-keyword">true</span>
  }
}
</code></pre>
<p>If <code>"allow"</code> is <code>true</code>, we return <code>true</code> — access granted.<br />If not, access is denied.</p>
<hr />
<h3 id="heading-connecting-it-all">Connecting It All</h3>
<p>Here’s how the flow works in practice:</p>
<ol>
<li><p>User sends a request with a Bearer token.</p>
</li>
<li><p>Controller method is protected with <code>@PreAuthorize("hasAuthorization(#authToken)")</code></p>
</li>
<li><p><code>CustomMethodSecurityExpressionRoot.hasAuthorization(...)</code> is triggered.</p>
</li>
<li><p><code>OpaService.checkPermission(...)</code> builds the input, queries OPA, and returns the result.</p>
</li>
<li><p>Based on the response, Spring either allows or blocks the request.</p>
</li>
</ol>
<hr />
<h3 id="heading-flowchart">Flowchart</h3>
<p>To better illustrate the flow, here's a visual representation of the authorization process:</p>
<pre><code class="lang-mermaid">sequenceDiagram
    participant User
    participant Controller
    participant CustomMethodSecurityExpressionRoot
    participant OpaService
    participant OPA

    User-&gt;&gt;Controller: Sends request with Bearer token
    Controller-&gt;&gt;CustomMethodSecurityExpressionRoot: @PreAuthorize("hasAuthorization(#authToken)")
    CustomMethodSecurityExpressionRoot-&gt;&gt;OpaService: checkPermission(authToken)
    OpaService-&gt;&gt;JWTService: extractClaim(token)
    JWTService--&gt;&gt;OpaService: role(s)
    OpaService-&gt;&gt;OPA: POST /v1/data/auth with input.role
    OPA--&gt;&gt;OpaService: { "result": { "allow": true/false } }
    OpaService--&gt;&gt;CustomMethodSecurityExpressionRoot: true/false
    CustomMethodSecurityExpressionRoot--&gt;&gt;Controller: true/false
    Controller--&gt;&gt;User: Access granted/denied
</code></pre>
<p>This flowchart demonstrates the sequence of interactions between the components involved in the authorization process.</p>
<hr />
<h2 id="heading-wrapping-it-up">Wrapping It Up</h2>
<p>You’ve now seen how to:</p>
<ul>
<li><p>Launch an OPA-backed system using Docker integrated with SpringBoot</p>
</li>
<li><p>Write a Policy based authorization flow</p>
</li>
<li><p>Update policies on the fly without downtime</p>
</li>
</ul>
<p>Next up: Try writing more advanced policies that check for multiple roles, query database-backed attributes, or using policies to drive feature flags!</p>
<p>You can externalize and evolve policies without changing your code!</p>
<p>Got ideas or questions? Drop them in the comments! 💬</p>
]]></content:encoded></item></channel></rss>