Quickstart: Zero-Trust Security via Access Policies
Enable authorization for Spring AI with access policies.
This quickstart demonstrates how to secure agent-to-agent communication using Catalyst access policies. You'll run two apps — a caller that invokes a server running a Spring AI tool-calling app — and then apply a deny policy to block unauthorized access.
This tutorial uses Catalyst Cloud to get started quickly with zero infrastructure setup. Catalyst Cloud uses a tunnel to route service invocation requests from the cloud to your local dev machine. For production or on-premises requirements, Diagrid also offers a self-hosted enterprise option where traffic stays within your private network.
You will learn how to:
- Use Catalyst service invocation for agent-to-agent communication
- Apply an access control configuration to enforce zero-trust authorization
- Observe a
403 PermissionDeniedresponse when a caller is blocked by policy
Your apps run entirely on your local machine — you are not deploying code to Catalyst. diagrid dev run opens a secure tunnel between Catalyst Cloud and your local processes and registers each app with an ID. When the caller sends a service invocation request, it travels up to Catalyst Cloud (not directly to the server). Catalyst evaluates the access policy there, in the cloud: if the policy allows it, the request is forwarded back down through the tunnel to the local server; if the policy denies it, Catalyst returns a 403 PermissionDenied immediately and the request never reaches your local server process.
1. Prerequisites
- Diagrid Catalyst account
- Diagrid CLI
- Java 21 or later
- Maven 3.9 or later
2. Log in to Catalyst
diagrid login
Confirm your identity:
diagrid whoami
3. Create project files
Create a new directory for the quickstart:
mkdir spring-ai-access-policies
cd spring-ai-access-policies
Create the Maven project folders:
- macOS/Linux
- Windows
mkdir -p caller/src/main/java/io/diagrid/quickstart/caller caller/src/main/resources
mkdir -p server/src/main/java/io/diagrid/quickstart/server server/src/main/resources
New-Item -ItemType Directory -Force caller/src/main/java/io/diagrid/quickstart/caller, caller/src/main/resources | Out-Null
New-Item -ItemType Directory -Force server/src/main/java/io/diagrid/quickstart/server, server/src/main/resources | Out-Null
server/pom.xml
Create server/pom.xml with the following content:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.8</version>
<relativePath/>
</parent>
<groupId>io.diagrid.quickstart</groupId>
<artifactId>server</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
<spring-ai.version>2.0.1</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- ChatClient, tool calling and the ChatModel interface. No model provider. -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
server/src/main/resources/application.properties
Create server/src/main/resources/application.properties with the following content:
server.port=8002
server/src/main/java/io/diagrid/quickstart/server/ServerApplication.java
Create the server app with the following content. It runs a Spring AI ChatClient behind a /research HTTP endpoint. A scripted ChatModel stands in for the LLM: it tells the client to call the gather_sources tool, then summarize, so the quickstart needs no LLM API key.
package io.diagrid.quickstart.server;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
/** Server app: exposes a Spring AI pipeline via HTTP. */
@SpringBootApplication
@RestController
public class ServerApplication {
// A scripted ChatModel stands in for the LLM, so no API key is needed.
private final ChatClient chatClient = ChatClient.builder(new ScriptedChatModel())
.defaultSystem("Call gather_sources, then summarize.")
.build();
record RunRequest(String topic) {}
@PostMapping("/research")
public Map<String, List<String>> research(@RequestBody RunRequest req) {
List<String> messages = new ArrayList<>();
chatClient.prompt()
.user(req.topic())
.tools(new ResearchTools(messages))
.call()
.content();
return Map.of("messages", messages);
}
@GetMapping("/health")
public Map<String, String> health() {
return Map.of("status", "ok");
}
public static void main(String[] args) {
SpringApplication.run(ServerApplication.class, args);
}
}
server/src/main/java/io/diagrid/quickstart/server/ResearchTools.java
Create ResearchTools.java next to it with the following content:
package io.diagrid.quickstart.server;
import java.util.List;
import org.springframework.ai.tool.annotation.Tool;
/** The two pipeline steps, as tools the model calls. Each result is also kept for the response. */
public class ResearchTools {
private final List<String> messages;
public ResearchTools(List<String> messages) {
this.messages = messages;
}
@Tool(name = "gather_sources", description = "Find papers on a topic.")
public String gatherSources(String topic) {
return record("gather_sources", "Found 42 papers on '" + topic + "'");
}
@Tool(name = "summarize", description = "Summarize the papers on a topic.")
public String summarize(String topic) {
return record("summarize", "Summary: AI diagnostics up 40% for '" + topic + "'");
}
private String record(String step, String result) {
System.out.println(">>> " + step + ": " + result);
messages.add(result);
return result;
}
}
server/src/main/java/io/diagrid/quickstart/server/ScriptedChatModel.java
Create ScriptedChatModel.java next to it with the following content:
package io.diagrid.quickstart.server;
import java.util.List;
import java.util.Map;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.util.json.JsonParser;
/**
* An offline ChatModel that plays a fixed script: call gather_sources, then summarize, then
* finish. The step is chosen by counting the tool results already in the conversation.
*/
public class ScriptedChatModel implements ChatModel {
private static final List<String> STEPS = List.of("gather_sources", "summarize");
@Override
public ChatOptions getOptions() {
return ToolCallingChatOptions.builder().build();
}
@Override
public ChatResponse call(Prompt prompt) {
List<Message> history = prompt.getInstructions();
String topic = history.stream()
.filter(m -> m instanceof UserMessage)
.reduce((first, last) -> last)
.map(Message::getText)
.orElse("");
int done = history.stream()
.filter(m -> m instanceof ToolResponseMessage)
.mapToInt(m -> ((ToolResponseMessage) m).getResponses().size())
.sum();
AssistantMessage reply = done < STEPS.size()
? AssistantMessage.builder()
.content("")
.toolCalls(List.of(new AssistantMessage.ToolCall(
"call_" + done, "function", STEPS.get(done),
JsonParser.toJson(Map.of("topic", topic)))))
.build()
: AssistantMessage.builder().content("Done.").build();
return new ChatResponse(List.of(new Generation(reply)));
}
}
caller/pom.xml
Create caller/pom.xml with the following content:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.8</version>
<relativePath/>
</parent>
<groupId>io.diagrid.quickstart</groupId>
<artifactId>caller</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
caller/src/main/resources/application.properties
Create caller/src/main/resources/application.properties with the following content:
server.port=8001
caller/src/main/java/io/diagrid/quickstart/caller/CallerApplication.java
Create the caller app with the following content. This app invokes the server's /research endpoint through Catalyst service invocation:
package io.diagrid.quickstart.caller;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestClient;
import tools.jackson.databind.json.JsonMapper;
/** Caller app: invokes the server app through Catalyst service invocation. */
@SpringBootApplication
@RestController
public class CallerApplication {
// Catalyst injects these environment variables via `diagrid dev run`
private static final String DAPR_HTTP_ENDPOINT =
System.getenv().getOrDefault("DAPR_HTTP_ENDPOINT", "http://localhost:3500");
private static final String DAPR_API_TOKEN = System.getenv().getOrDefault("DAPR_API_TOKEN", "");
private final RestClient http = RestClient.create();
private final JsonMapper json;
public CallerApplication(JsonMapper json) {
this.json = json;
}
record CallRequest(String topic) {}
@PostMapping("/call-server")
public Map<String, Object> callServer(@RequestBody CallRequest req) {
String url = DAPR_HTTP_ENDPOINT + "/v1.0/invoke/server/method/research";
System.out.println(">>> Calling server via Catalyst: " + url);
ResponseEntity<String> resp = http.post()
.uri(url)
.header("dapr-api-token", DAPR_API_TOKEN)
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("topic", req.topic()))
.exchange((request, response) -> ResponseEntity
.status(response.getStatusCode())
.body(new String(response.getBody().readAllBytes())));
int status = resp.getStatusCode().value();
System.out.println(">>> Response status: " + status);
System.out.println(">>> Response body: " + resp.getBody());
Map<String, Object> result = new LinkedHashMap<>();
result.put("status_code", status);
result.put("body", status == 200 ? json.readTree(resp.getBody()) : resp.getBody());
return result;
}
@GetMapping("/health")
public Map<String, String> health() {
return Map.of("status", "ok");
}
public static void main(String[] args) {
SpringApplication.run(CallerApplication.class, args);
}
}
dev.yaml
Create dev.yaml in the project root:
version: 1
common:
appLogDestination: console
apps:
- appID: caller
appDirPath: ./caller
command: ["mvn", "-q", "spring-boot:run"]
- appID: server
appDirPath: ./server
appPort: 8002
command: ["mvn", "-q", "spring-boot:run"]
The appPort field on the server tells Catalyst which local port the app listens on. This is required for apps that receive service invocation requests. The caller does not need appPort because it only sends requests.
4. Install dependencies
Download the dependencies and build both apps, so diagrid dev run starts them quickly:
- macOS/Linux
- Windows
cd server && mvn -q package -DskipTests && cd ..
cd caller && mvn -q package -DskipTests && cd ..
cd server; mvn -q package -DskipTests; cd ..
cd caller; mvn -q package -DskipTests; cd ..
5. Run with default allow policy
Start both apps with Catalyst Cloud:
diagrid dev run -f dev.yaml --project spring-ai-access-qs --approve
Wait for both apps to show Tomcat started on port ... in the log output before proceeding.
Open a new terminal and invoke the caller:
- macOS/Linux
- Windows
curl -X POST http://localhost:8001/call-server \
-H "Content-Type: application/json" \
-d '{"topic": "AI in healthcare"}'
Invoke-RestMethod -Method Post -Uri "http://localhost:8001/call-server" `
-ContentType "application/json" `
-Body '{"topic": "AI in healthcare"}'
You should see a successful response:
{
"status_code": 200,
"body": {
"messages": [
"Found 42 papers on 'AI in healthcare'",
"Summary: AI diagnostics up 40% for 'AI in healthcare'"
]
}
}
By default, Catalyst allows all agent-to-agent communication. The request traveled from your local caller → Catalyst Cloud → through the tunnel → your local server. The caller successfully invoked the server's Spring AI app.
6. Apply a deny policy
Stop the running application by pressing Ctrl+C, then disconnect the apps from Catalyst:
diagrid dev stop --id caller --project spring-ai-access-qs
diagrid dev stop --id server --project spring-ai-access-qs
Deny every caller on the server app:
diagrid app access grant server --project spring-ai-access-qs --caller '*' --action deny --wait
If the command reports local app connection already exists, wait a few seconds and run it again.
Passing '*' as the caller sets the policy's default action instead of naming one app, so this denies all incoming service invocation requests to the server, regardless of which app is calling it. Because authorization is enforced in Catalyst Cloud — not by the server process — a denied request never reaches your local server. The 403 is returned by Catalyst before any traffic enters the tunnel.
7. Verify access is denied
Restart both apps:
diagrid dev run -f dev.yaml --project spring-ai-access-qs --approve
Open a new terminal and invoke the caller again:
- macOS/Linux
- Windows
curl -X POST http://localhost:8001/call-server \
-H "Content-Type: application/json" \
-d '{"topic": "AI in healthcare"}'
Invoke-RestMethod -Method Post -Uri "http://localhost:8001/call-server" `
-ContentType "application/json" `
-Body '{"topic": "AI in healthcare"}'
This time, the caller receives a 403 PermissionDenied error:
{
"status_code": 403,
"body": "{\"errorCode\":\"ERR_DIRECT_INVOKE\",\"message\":\"failed to invoke, id: server, err: rpc error: code = PermissionDenied desc = access control policy has denied access to id: spiffe://<trust-domain>/ns/<project-id>/caller operation: research verb: POST\"}"
}
The access policy blocked the caller from invoking the server. The server's Spring AI app never executed.
8. Clean up
Stop the running application by pressing Ctrl+C.
Delete the Catalyst Cloud project:
diagrid project delete spring-ai-access-qs
Summary
In this quickstart, you:
- Set up two apps communicating through Catalyst service invocation
- Verified that agent-to-agent calls succeed with the default allow policy
- Applied a deny-all access policy that blocked the caller with a
403 PermissionDeniederror
Next steps
- Learn more about Catalyst Enterprise for fine-grained authorization rules
- Explore the Catalyst web console for managing configurations and apps