Skip to main content

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 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 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 PermissionDenied response when a caller is blocked by policy
Caller to server through Catalyst service invocationCatalystCaller appcallerApp IDAccesspolicyserverApp IDAgent Frameworkserver
Caller to server through Catalyst service invocationCatalyst checks the access policy before it forwards the call. A denied call gets a 403 and never reaches the 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​

2. Log in to Catalyst​

diagrid login

Confirm your identity:

diagrid whoami

3. Create project files​

Create a new directory for the quickstart:

mkdir microsoft-access-policies
cd microsoft-access-policies
mkdir caller
mkdir server

server/server.csproj​

Create server/server.csproj with the following content:

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

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

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

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

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:

cd server && dotnet build && cd ..
cd caller && dotnet build && cd ..

5. Run with default allow policy​

Start both apps with Catalyst Cloud:

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:

curl -X POST http://localhost:8001/call-server \
-H "Content-Type: application/json" \
-d '{"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 Microsoft Agent Framework agent.

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

Deny every caller on the server app:

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:

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

Open a new terminal and invoke the caller again:

curl -X POST http://localhost:8001/call-server \
-H "Content-Type: application/json" \
-d '{"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 Microsoft Agent Framework agent never executed.

8. Clean up​

Stop the running application by pressing Ctrl+C.

Delete the Catalyst Cloud project:

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​