# Quickstart: Zero-Trust Security via Access Policies

Enable authorization for LangGraph 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 LangGraph pipeline — 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

```mermaid
---
title: Caller → Server via Catalyst Service Invocation
---
flowchart LR
  CALLER(Caller App)
  subgraph Catalyst
    CID(caller)
    SID(server)
    POLICY{Access Policy}:::decision
  end
  SERVER(LangGraph Server)

  CALLER-->CID
  CID-->POLICY
  POLICY-->SID
  SID-->SERVER

  classDef decision stroke:#ed8936
```

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)
- [Python 3.11, 3.12, or 3.13](https://www.python.org/downloads/)

## 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 langgraph-access-policies && cd langgraph-access-policies
mkdir caller server
```

### server/app.py

Create `server/app.py` with the following content. This defines a simple LangGraph pipeline exposed via a `/research` HTTP endpoint:

```python
"""Server app: exposes a LangGraph pipeline via HTTP."""
from typing import List, TypedDict

import uvicorn
from fastapi import FastAPI
from pydantic import BaseModel
from langgraph.graph import StateGraph, START, END

# ── Graph state ──────────────────────────────────────────────
class ResearchState(TypedDict):
    topic: str
    messages: List[str]

# ── Graph nodes ──────────────────────────────────────────────
def gather_sources(state: ResearchState) -> dict:
    result = f"Found 42 papers on '{state['topic']}'"
    print(f">>> gather_sources: {result}", flush=True)
    return {"messages": state["messages"] + [result]}

def summarize(state: ResearchState) -> dict:
    result = f"Summary: AI diagnostics up 40% for '{state['topic']}'"
    print(f">>> summarize: {result}", flush=True)
    return {"messages": state["messages"] + [result]}

# ── Build graph ──────────────────────────────────────────────
graph = StateGraph(ResearchState)
graph.add_node("gather_sources", gather_sources)
graph.add_node("summarize", summarize)
graph.add_edge(START, "gather_sources")
graph.add_edge("gather_sources", "summarize")
graph.add_edge("summarize", END)
compiled = graph.compile()

# ── FastAPI ──────────────────────────────────────────────────
app = FastAPI()

class RunRequest(BaseModel):
    topic: str

@app.post("/research")
async def research(req: RunRequest):
    result = compiled.invoke({"topic": req.topic, "messages": []})
    return {"messages": result["messages"]}

@app.get("/health")
async def health():
    return {"status": "ok"}

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8002)
```

### caller/app.py

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

```python
"""Caller app: invokes the server app through Catalyst service invocation."""
import os

import httpx
import uvicorn
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

# Catalyst injects these environment variables via `diagrid dev run`
DAPR_HTTP_ENDPOINT = os.getenv("DAPR_HTTP_ENDPOINT", "http://localhost:3500")
DAPR_API_TOKEN = os.getenv("DAPR_API_TOKEN", "")

class CallRequest(BaseModel):
    topic: str

@app.post("/call-server")
async def call_server(req: CallRequest):
    """Call the server app's /research endpoint via Catalyst service invocation."""
    url = f"{DAPR_HTTP_ENDPOINT}/v1.0/invoke/server/method/research"
    headers = {
        "dapr-api-token": DAPR_API_TOKEN,
        "Content-Type": "application/json",
    }
    print(f">>> Calling server via Catalyst: {url}", flush=True)
    async with httpx.AsyncClient() as client:
        resp = await client.post(url, json={"topic": req.topic}, headers=headers, timeout=30)
    print(f">>> Response status: {resp.status_code}", flush=True)
    print(f">>> Response body: {resp.text}", flush=True)
    return {"status_code": resp.status_code, "body": resp.json() if resp.status_code == 200 else resp.text}

@app.get("/health")
async def health():
    return {"status": "ok"}

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8001)
```

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: [".venv/bin/python", "app.py"]
  - appID: server
    appDirPath: ./server
    appPort: 8002
    command: [".venv/bin/python", "app.py"]
```

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

**macOS/Linux**

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install langgraph fastapi uvicorn httpx
```

**Windows**

```powershell
python -m venv .venv
.venv\Scripts\activate
pip install langgraph fastapi uvicorn httpx
```

Create symlinks so both apps share the same virtual environment:

**macOS/Linux**

```bash
ln -s ../.venv caller/.venv
ln -s ../.venv server/.venv
```

**Windows**

```powershell
cmd /c mklink /D caller\.venv ..\.venv
cmd /c mklink /D server\.venv ..\.venv
```

## 5. Run with default allow policy

Start both apps with Catalyst Cloud:

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

:::tip
Wait for both apps to show `Uvicorn running on ...` 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 LangGraph pipeline.

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

Deny every caller on the server app:

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

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 langgraph-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": "access control policy has denied access"
}
```

The access policy blocked the caller from invoking the server. The server's LangGraph pipeline never executed.

## 8. Clean up

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

Delete the Catalyst Cloud project:

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