# 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](https://catalyst.diagrid.io/) 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.

:::note
This tutorial uses [**Catalyst Cloud**](https://docs.diagrid.io/operate/hosting/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](https://docs.diagrid.io/operate/hosting/enterprise-self-hosted) 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 PermissionDenied` response when a caller is blocked by policy

**Diagram: Caller to server through Catalyst service invocation.** Catalyst checks the access policy before it forwards the call. A denied call gets a 403 and never reaches the server.

- **Catalyst**: caller (App ID), Access policy, server (App ID)
- Caller app, Spring AI (server)

- Caller app → caller (App ID)
- caller (App ID) → Access policy
- Access policy → server (App ID)
- server (App ID) → Spring AI (server)

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](https://catalyst.diagrid.io/)
- [Diagrid CLI](https://docs.diagrid.io/references/catalyst/catalyst-cli-intro)
- [Java 21](https://adoptium.net/) or later
- [Maven 3.9](https://maven.apache.org/download.cgi) or later

## 2. Log in to Catalyst

```bash
diagrid login
```

Confirm your identity:

```bash
diagrid whoami
```

## 3. Create project files

Create a new directory for the quickstart:

```bash
mkdir spring-ai-access-policies
cd spring-ai-access-policies
```

Create the Maven project folders:

**macOS/Linux**

```bash
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
```

**Windows**

```powershell
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
<?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:

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

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

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

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

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

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

```yaml
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"]
```

:::tip
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**

```bash
cd server && mvn -q package -DskipTests && cd ..
cd caller && mvn -q package -DskipTests && cd ..
```

**Windows**

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

```bash
diagrid dev run -f dev.yaml --project spring-ai-access-qs --approve
```

:::tip
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**

```bash
curl -X POST http://localhost:8001/call-server \
  -H "Content-Type: application/json" \
  -d '{"topic": "AI in healthcare"}'
```

**Windows**

```powershell
Invoke-RestMethod -Method Post -Uri "http://localhost:8001/call-server" `
  -ContentType "application/json" `
  -Body '{"topic": "AI in healthcare"}'
```

You should see a successful response:

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

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

```bash
diagrid app access grant server --project spring-ai-access-qs --caller '*' --action deny --wait
```

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

```bash
diagrid dev run -f dev.yaml --project spring-ai-access-qs --approve
```

Open a **new terminal** and invoke the caller again:

**macOS/Linux**

```bash
curl -X POST http://localhost:8001/call-server \
  -H "Content-Type: application/json" \
  -d '{"topic": "AI in healthcare"}'
```

**Windows**

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

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

```bash
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 PermissionDenied` error

## Next steps

- Learn more about [Catalyst Enterprise](https://docs.diagrid.io/operate/hosting/enterprise-self-hosted) for fine-grained authorization rules
- Explore the [Catalyst web console](https://catalyst.diagrid.io/) for managing configurations and apps
