# Quickstart: Zero-Trust Security via Access Policies

Enable authorization for Microsoft Agent Framework 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 Microsoft Agent Framework agent in .NET — 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, Agent Framework (server)

- Caller app → caller (App ID)
- caller (App ID) → Access policy
- Access policy → server (App ID)
- server (App ID) → Agent Framework (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)
- [.NET 10 SDK](https://dotnet.microsoft.com/download)

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

### server/server.csproj

Create `server/server.csproj` with the following content:

```xml
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Agents.AI" Version="1.24.0" />
  </ItemGroup>

</Project>
```

### server/Program.cs

Create `server/Program.cs` with the following content. It runs a Microsoft Agent Framework agent behind a `/research` HTTP endpoint. A scripted chat client stands in for the LLM: it tells the agent to call the `gather_sources` tool, then `summarize`, so the quickstart needs no LLM API key.

```csharp
// Server app: exposes a Microsoft Agent Framework agent via HTTP.
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

// ── Tools: the two pipeline steps ────────────────────────────
var tools = new List<AITool>
{
    AIFunctionFactory.Create((string topic) =>
    {
        var result = $"Found 42 papers on '{topic}'";
        Console.WriteLine($">>> gather_sources: {result}");
        return result;
    }, "gather_sources", "Find papers on a topic."),

    AIFunctionFactory.Create((string topic) =>
    {
        var result = $"Summary: AI diagnostics up 40% for '{topic}'";
        Console.WriteLine($">>> summarize: {result}");
        return result;
    }, "summarize", "Summarize the papers on a topic."),
};

// ── Agent: a scripted chat client stands in for the LLM ──────
AIAgent agent = new ScriptedChatClient().AsAIAgent(
    instructions: "Call gather_sources, then summarize.",
    name: "researcher",
    tools: tools);

// ── ASP.NET Core ─────────────────────────────────────────────
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://0.0.0.0:8002");
builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
var app = builder.Build();

app.MapPost("/research", async (RunRequest req) =>
{
    var response = await agent.RunAsync(req.Topic);
    // The tool results, in the order the agent ran them.
    var messages = response.Messages
        .SelectMany(m => m.Contents)
        .OfType<FunctionResultContent>()
        .Select(r => r.Result is JsonElement json ? json.GetString() : r.Result?.ToString())
        .ToList();
    return Results.Ok(new { messages });
});

app.MapGet("/health", () => Results.Ok(new { status = "ok" }));

app.Run();

record RunRequest(string Topic);

// An offline IChatClient that plays a fixed script, so no API key is needed:
// call gather_sources, then summarize, then finish. The step is chosen by
// counting the tool results already in the conversation.
sealed class ScriptedChatClient : IChatClient
{
    public Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
    {
        var history = messages.ToList();
        var topic = history.Last(m => m.Role == ChatRole.User).Text;
        var done = history.SelectMany(m => m.Contents).OfType<FunctionResultContent>().Count();

        AIContent content = done switch
        {
            0 => new FunctionCallContent("call_1", "gather_sources", new Dictionary<string, object?> { ["topic"] = topic }),
            1 => new FunctionCallContent("call_2", "summarize", new Dictionary<string, object?> { ["topic"] = topic }),
            _ => new TextContent("Done."),
        };
        return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, [content])));
    }

    public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
        throw new NotSupportedException();

    public object? GetService(Type serviceType, object? serviceKey = null) =>
        serviceKey is null && serviceType.IsInstanceOfType(this) ? this : null;

    public void Dispose() { }
}
```

### caller/caller.csproj

Create `caller/caller.csproj` with the following content:

```xml
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

</Project>
```

### caller/Program.cs

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

```csharp
// Caller app: invokes the server app through Catalyst service invocation.
using System.Net.Http.Json;
using System.Text.Json;

// Catalyst injects these environment variables via `diagrid dev run`
var daprHttpEndpoint = Environment.GetEnvironmentVariable("DAPR_HTTP_ENDPOINT") ?? "http://localhost:3500";
var daprApiToken = Environment.GetEnvironmentVariable("DAPR_API_TOKEN") ?? "";

var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://0.0.0.0:8001");
builder.Logging.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
var app = builder.Build();

var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };

app.MapPost("/call-server", async (CallRequest req) =>
{
    var url = $"{daprHttpEndpoint}/v1.0/invoke/server/method/research";
    using var request = new HttpRequestMessage(HttpMethod.Post, url)
    {
        Content = JsonContent.Create(new { topic = req.Topic }),
    };
    request.Headers.Add("dapr-api-token", daprApiToken);

    Console.WriteLine($">>> Calling server via Catalyst: {url}");
    using var resp = await http.SendAsync(request);
    var text = await resp.Content.ReadAsStringAsync();
    var status = (int)resp.StatusCode;
    Console.WriteLine($">>> Response status: {status}");
    Console.WriteLine($">>> Response body: {text}");

    object body = status == 200 ? JsonSerializer.Deserialize<JsonElement>(text) : text;
    return Results.Ok(new { status_code = status, body });
});

app.MapGet("/health", () => Results.Ok(new { status = "ok" }));

app.Run();

record CallRequest(string Topic);
```

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: ["dotnet", "run"]
  - appID: server
    appDirPath: ./server
    appPort: 8002
    command: ["dotnet", "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

Restore and build both apps, so `diagrid dev run` doesn't download packages while it starts them:

**macOS/Linux**

```bash
cd server && dotnet build && cd ..
cd caller && dotnet build && cd ..
```

**Windows**

```powershell
cd server; dotnet build; cd ..
cd caller; dotnet build; cd ..
```

## 5. Run with default allow policy

Start both apps with Catalyst Cloud:

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

:::tip
Wait for both apps to show `Now listening 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 Microsoft Agent Framework agent.

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

Deny every caller on the server app:

```bash
diagrid app access grant server --project microsoft-dotnet-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 microsoft-dotnet-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 Microsoft Agent Framework agent never executed.

## 8. Clean up

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

Delete the Catalyst Cloud project:

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