# Quickstart: Zero-Trust Security via Access Policies

Enable authorization for Go 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 Go AI agent graph — 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, Go AI (server)

- Caller app → caller (App ID)
- caller (App ID) → Access policy
- Access policy → server (App ID)
- server (App ID) → Go 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)
- [Go 1.26.4](https://go.dev/dl/) 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 go-ai-access-policies
cd go-ai-access-policies
```

Create the app folders:

```bash
mkdir server
mkdir caller
```

### server/go.mod

Create `server/go.mod` with the following content:

```text
module server

go 1.26.4

require (
	github.com/diagridio/go-ai v0.2.0
	github.com/diagridio/go-ai/adapters/langchaingo v0.1.1
	github.com/tmc/langchaingo v0.1.15-0.20251029190607-e35755df7084
)
```

### server/main.go

Create `server/main.go` with the following content. It runs a two-node Go AI agent graph behind a `/research` HTTP endpoint.

:::note
The graph's nodes call LangChainGo's fake model, which returns scripted replies in order, so the quickstart needs no LLM API key.
:::

```go
// Server app: exposes a go-ai agent graph via HTTP.
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"

	"github.com/diagridio/go-ai/adapters/langchaingo"
	"github.com/diagridio/go-ai/agent"
	"github.com/tmc/langchaingo/llms/fake"
)

// logged prints each node's reply as it runs.
func logged(name string, node agent.NodeFunc) agent.NodeFunc {
	return func(ctx context.Context, s agent.State) (agent.State, error) {
		out, err := node(ctx, s)
		if err == nil {
			fmt.Printf(">>> %s: %v\n", name, out["messages"])
		}
		return out, err
	}
}

// research builds the two-node graph for one topic. LangChainGo's fake LLM
// stands in for a real model: it returns its scripted replies in order, so no
// API key is needed.
func research(ctx context.Context, topic string) ([]any, error) {
	model := fake.NewFakeLLM([]string{
		fmt.Sprintf("Found 42 papers on '%s'", topic),
		fmt.Sprintf("Summary: AI diagnostics up 40%% for '%s'", topic),
	})
	node := func(name string) agent.NodeFunc {
		return logged(name, langchaingo.ModelNode(model,
			langchaingo.WithInputKey("topic"),
			langchaingo.WithOutputKey("messages"),
		))
	}

	graph, err := agent.NewGraph("research").
		AddNode("gather_sources", node("gather_sources")).
		AddNode("summarize", node("summarize")).
		AddEdge(agent.START, "gather_sources").
		AddEdge("gather_sources", "summarize").
		AddEdge("summarize", agent.END).
		WithReducer("messages", agent.AppendReducer).
		Compile()
	if err != nil {
		return nil, err
	}

	out, err := agent.Execute(ctx, graph, agent.State{"topic": topic}, agent.RunOptions{})
	if err != nil {
		return nil, err
	}
	messages, _ := out["messages"].([]any)
	return messages, nil
}

func writeJSON(w http.ResponseWriter, status int, body any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(body)
}

func main() {
	http.HandleFunc("POST /research", func(w http.ResponseWriter, r *http.Request) {
		var req struct {
			Topic string `json:"topic"`
		}
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
			return
		}
		messages, err := research(r.Context(), req.Topic)
		if err != nil {
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
			return
		}
		writeJSON(w, http.StatusOK, map[string]any{"messages": messages})
	})

	http.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
	})

	log.Println("Server listening on :8002")
	log.Fatal(http.ListenAndServe(":8002", nil))
}
```

This server runs the graph in process with `agent.Execute`, which needs no Dapr workflow runtime. For durable execution, run the graph with `goai.NewRunner` instead, as shown in [Durable Go agents](https://docs.diagrid.io/develop/agents/go-ai/go-ai-durable-agents).

### caller/go.mod

Create `caller/go.mod` with the following content:

```text
module caller

go 1.22
```

### caller/main.go

Create `caller/main.go` with the following content. This app invokes the server's `/research` endpoint through Catalyst service invocation:

```go
// Caller app: invokes the server app through Catalyst service invocation.
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"time"
)

func getenv(key, fallback string) string {
	if v, ok := os.LookupEnv(key); ok {
		return v
	}
	return fallback
}

// Catalyst injects these environment variables via `diagrid dev run`
var (
	daprHTTPEndpoint = getenv("DAPR_HTTP_ENDPOINT", "http://localhost:3500")
	daprAPIToken     = getenv("DAPR_API_TOKEN", "")
	client           = &http.Client{Timeout: 30 * time.Second}
)

func writeJSON(w http.ResponseWriter, status int, body any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(body)
}

func callServer(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Topic string `json:"topic"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}

	// Call the server app's /research endpoint via Catalyst service invocation.
	url := daprHTTPEndpoint + "/v1.0/invoke/server/method/research"
	payload, _ := json.Marshal(map[string]string{"topic": req.Topic})
	httpReq, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, url, bytes.NewReader(payload))
	httpReq.Header.Set("dapr-api-token", daprAPIToken)
	httpReq.Header.Set("Content-Type", "application/json")

	fmt.Printf(">>> Calling server via Catalyst: %s\n", url)
	resp, err := client.Do(httpReq)
	if err != nil {
		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
		return
	}
	defer resp.Body.Close()
	text, _ := io.ReadAll(resp.Body)
	fmt.Printf(">>> Response status: %d\n", resp.StatusCode)
	fmt.Printf(">>> Response body: %s\n", text)

	result := struct {
		StatusCode int `json:"status_code"`
		Body       any `json:"body"`
	}{resp.StatusCode, string(text)}
	if resp.StatusCode == http.StatusOK {
		result.Body = json.RawMessage(text)
	}
	writeJSON(w, http.StatusOK, result)
}

func main() {
	http.HandleFunc("POST /call-server", callServer)
	http.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
	})

	log.Println("Caller listening on :8001")
	log.Fatal(http.ListenAndServe(":8001", nil))
}
```

The caller uses `DAPR_HTTP_ENDPOINT` and `DAPR_API_TOKEN`, which are automatically injected by `diagrid dev run`. The service invocation URL follows the pattern:

```
{DAPR_HTTP_ENDPOINT}/v1.0/invoke/{target-app-id}/method/{endpoint}
```

### dev.yaml

Create `dev.yaml` in the project root:

```yaml
version: 1
common:
  appLogDestination: console
apps:
  - appID: caller
    appDirPath: ./caller
    command: ["go", "run", "."]
  - appID: server
    appDirPath: ./server
    appPort: 8002
    command: ["go", "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 server's modules and build both apps, so `diagrid dev run` doesn't download anything while it starts them:

**macOS/Linux**

```bash
cd server && go mod tidy && go build ./... && cd ..
cd caller && go build ./... && cd ..
```

**Windows**

```powershell
cd server; go mod tidy; go build ./...; cd ..
cd caller; go build ./...; cd ..
```

## 5. Run with default allow policy

Start both apps with Catalyst Cloud:

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

:::tip
Wait for the log to show `Server listening on :8002` and `Caller listening on :8001` 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 Go AI graph.

## 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 go-ai-access-qs
diagrid dev stop --id server --project go-ai-access-qs
```

Deny every caller on the server app:

```bash
diagrid app access grant server --project go-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 go-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 Go AI graph never executed.

## 8. Clean up

Stop the running application by pressing `Ctrl+C`.

Delete the Catalyst Cloud project:

```bash
diagrid project delete go-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
