CrossClusterAuthorization source
This page is part of the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at source.md, and llms.txt lists every page.The source of the CrossClusterAuthorization sample.
Program.cs
using System.Text;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Orleans.Lattice;
using Orleans.Lattice.Auth;
using Orleans.Lattice.Membership;
using Orleans.Lattice.Samples.CrossClusterAuthorization;
// ---------------------------------------------------------------------------
// CrossClusterAuthorization - the opt-in authorization layer end to end,
// converging across two clusters.
//
// Two in-process Orleans clusters (site-a, site-b) run the full stack:
// Membership (identity) + Auth (a default-deny enforcement gate), with the
// reserved membership/auth system trees enrolled into cross-cluster
// replication. The sample walks four acts:
//
// 1. Create users and groups in the membership directory.
// 2. Author per-tree, per-key and per-prefix rules for a user and for groups,
// then show read / write / delete / range enforcement (allow vs deny).
// 3. Show read visibility: a low-privilege caller cannot see (point-read or
// range-read) entries it lacks read permission for. This is the same
// per-key read filtering the read-only State API surfaces to the Explorer.
// 4. Converge a revoke: remove a grant on site-a and watch it become enforced
// on site-b, purely via the system-tree replication special case.
//
// Denied writes throw LatticeAuthorizationDeniedException (fail-closed); denied
// reads return null / an empty range (soft-deny), so a caller sees only what it
// is allowed to see.
// ---------------------------------------------------------------------------
// gRPC over plaintext HTTP/2 (h2c) for the loopback replication transport: each
// site binds Kestrel to HTTP/2 with no certificate and grpc-dotnet speaks h2c by
// prior knowledge over an http:// address.
const string Tree = SiteFactory.TreeName;
const string Scheme = DemoAuthenticator.Scheme;
// Data keys: three "station/" entries (prefix scope), one "config/" key (key
// scope) and one "secret/" key only auditors may read (tree scope).
string[] stationKeys = ["station/1/status", "station/2/status", "station/3/status"];
const string ConfigKey = "config/threshold";
const string SecretKey = "secret/recipe";
var siteA = new SiteConfig(
ClusterId: "site-a", SiloPort: 11111, GatewayPort: 30000,
GrpcPort: 17001, PeerClusterId: "site-b", PeerGrpcPort: 17002);
var siteB = new SiteConfig(
ClusterId: "site-b", SiloPort: 11112, GatewayPort: 30001,
GrpcPort: 17002, PeerClusterId: "site-a", PeerGrpcPort: 17001);
var appA = SiteFactory.Build(siteA);
var appB = SiteFactory.Build(siteB);
Console.WriteLine("Starting two Orleans clusters (site-a, site-b) with the auth stack...");
await appA.StartAsync();
await appB.StartAsync();
Console.WriteLine("Both clusters ready and peered over gRPC.\n");
var dirA = appA.Services.GetRequiredService<ILatticeMembershipDirectory>();
var dirB = appB.Services.GetRequiredService<ILatticeMembershipDirectory>();
var storeA = appA.Services.GetRequiredService<ILatticeAuthorizationPolicyStore>();
var storeB = appB.Services.GetRequiredService<ILatticeAuthorizationPolicyStore>();
var treeA = appA.Services.GetRequiredService<IGrainFactory>().GetGrain<ILattice>(Tree);
var treeB = appB.Services.GetRequiredService<IGrainFactory>().GetGrain<ILattice>(Tree);
// -- Act 1: identities -------------------------------------------------------
// Seed the same users/groups on both sites directly (the membership trees also
// replicate, but seeding both keeps the non-convergence acts deterministic).
// alice -> line-operators (manages the stations)
// bob -> auditors (reads everything, writes nothing)
// carol -> no groups (low-privilege)
Console.WriteLine("== Act 1: create users and groups ==");
// Seeding writes to the reserved sys-membership-* / sys-auth-policy trees, which
// the silo-side membership directory and policy store do under system origin,
// so it needs no grant (a user-origin write to a sys- tree is refused outright,
// Admin or not). The sample still wraps it in the bootstrap administrator's
// credential (declared in SiteFactory), as it does the data seeding below that
// does need it.
using (LatticeCredentialContext.Use("root-admin", scheme: Scheme))
{
foreach (var dir in new[] { dirA, dirB })
{
await dir.UpsertGroupAsync(new MembershipGroup("line-operators", "Line operators"));
await dir.UpsertGroupAsync(new MembershipGroup("auditors", "Auditors"));
await dir.AddMemberAsync("line-operators", "alice");
await dir.AddMemberAsync("auditors", "bob");
}
}
Console.WriteLine(" alice in line-operators, bob in auditors, carol in no group.\n");
// -- Act 2: rules + enforcement ---------------------------------------------
// Author the base ruleset on both sites (default-deny, so only these grant
// access). Rule ids let us revoke one later.
Console.WriteLine("== Act 2: author per-tree / per-key / per-prefix rules ==");
using (LatticeCredentialContext.Use("root-admin", scheme: Scheme))
{
foreach (var store in new[] { storeA, storeB })
{
// Prefix scope: operators read/write/delete/range the "station/" subtree.
await store.PutRuleAsync(new LatticeAuthorizationRule(
"operators-stations",
LatticeSubjectSelector.Group("line-operators"),
LatticeScope.Prefix(Tree, "station/"),
LatticeOperation.Read | LatticeOperation.Write | LatticeOperation.Delete | LatticeOperation.RangeRead,
LatticeEffect.Allow));
// Key scope: only alice may read/write the single config threshold key.
await store.PutRuleAsync(new LatticeAuthorizationRule(
"alice-config",
LatticeSubjectSelector.User("alice"),
LatticeScope.Key(Tree, ConfigKey),
LatticeOperation.Read | LatticeOperation.Write,
LatticeEffect.Allow));
// Tree scope: auditors read (and range-read) the whole tree, nothing more.
await store.PutRuleAsync(new LatticeAuthorizationRule(
"auditors-readall",
LatticeSubjectSelector.Group("auditors"),
LatticeScope.Tree(Tree),
LatticeOperation.Read | LatticeOperation.RangeRead,
LatticeEffect.Allow));
}
}
// Seed the data as the bootstrap admin (which bypasses the gate) so every key
// exists before we demonstrate who can see it.
using (LatticeCredentialContext.Use("root-admin", scheme: Scheme))
{
foreach (var key in stationKeys)
{
await treeA.SetAsync(key, Encoding.UTF8.GetBytes("ok"));
}
await treeA.SetAsync(ConfigKey, Encoding.UTF8.GetBytes("42"));
await treeA.SetAsync(SecretKey, Encoding.UTF8.GetBytes("caramel"));
}
// Wait for the compiled policy snapshot to reflect the authored rules (it
// rebuilds off the policy-tree change feed) before asserting enforcement.
await WaitUntilAsync(
async () => await CanAsync(treeA, "alice", stationKeys[0]),
TimeSpan.FromSeconds(15));
Console.WriteLine(" As alice (line-operators):");
Console.WriteLine($" write {stationKeys[0]} -> {await WriteOutcome(treeA, "alice", stationKeys[0], "running")}");
Console.WriteLine($" write {ConfigKey} -> {await WriteOutcome(treeA, "alice", ConfigKey, "50")}");
Console.WriteLine($" write {SecretKey} -> {await WriteOutcome(treeA, "alice", SecretKey, "leak")} (no rule -> deny)");
Console.WriteLine(" As bob (auditors, read-only):");
Console.WriteLine($" read {SecretKey} -> {await ReadOutcome(treeA, "bob", SecretKey)}");
Console.WriteLine($" write {stationKeys[1]} -> {await WriteOutcome(treeA, "bob", stationKeys[1], "stop")} (read-only -> deny)");
Console.WriteLine($" delete {stationKeys[1]} -> {await DeleteOutcome(treeA, "bob", stationKeys[1])} (read-only -> deny)");
Console.WriteLine(" As alice (line-operators):");
Console.WriteLine($" delete {stationKeys[2]} -> {await DeleteOutcome(treeA, "alice", stationKeys[2])} (prefix grant allows delete)\n");
// -- Act 3: read visibility --------------------------------------------------
Console.WriteLine("== Act 3: read visibility (point + range) ==");
Console.WriteLine(" Point read of the secret recipe:");
Console.WriteLine($" bob -> {await ReadOutcome(treeA, "bob", SecretKey)} (auditor)");
Console.WriteLine($" carol -> {await ReadOutcome(treeA, "carol", SecretKey)} (low-privilege: soft-denied)");
Console.WriteLine(" Range read of the whole tree returns only authorized keys:");
Console.WriteLine($" bob sees {await RangeCount(treeA, "bob")} keys (auditor: all)");
Console.WriteLine($" alice sees {await RangeCount(treeA, "alice")} keys (her station keys)");
Console.WriteLine($" carol sees {await RangeCount(treeA, "carol")} keys (nothing)\n");
// -- Act 4: converge a revoke across clusters -------------------------------
// The authorization policy tree is one of the reserved system trees enrolled
// into cross-cluster replication (the "system-tree replication special case").
// A revoke authored on site-a therefore propagates to site-b's policy tree.
// Each site's gate keeps a compiled read-through snapshot of that tree and
// refreshes it when it observes the policy change; here we assert on the
// authoritative convergence signal - the rule vanishing from site-b's tree -
// and additionally report whether site-b's live gate has already picked it up.
Console.WriteLine("== Act 4: a revoke on site-a converges to site-b ==");
Console.WriteLine($" Before: alice writing {ConfigKey} on site-b -> {await WriteOutcome(treeB, "alice", ConfigKey, "60")}");
// Revoke alice's config-key grant on site-a only.
using (LatticeCredentialContext.Use("root-admin", scheme: Scheme))
{
await storeA.RemoveRuleAsync(Tree, "alice-config");
}
Console.WriteLine(" Revoked 'alice-config' on site-a only. Waiting for site-b to converge...");
// Poll site-b until the revoke has replicated into its policy tree.
var sw = System.Diagnostics.Stopwatch.StartNew();
var timeout = TimeSpan.FromSeconds(60);
bool ruleGoneOnB = false;
bool gateDenies = false;
while (sw.Elapsed < timeout)
{
ruleGoneOnB = await AsAsync("root-admin", async () =>
await storeB.GetRuleAsync(Tree, "alice-config") is null);
gateDenies = !await CanAsync(treeB, "alice", ConfigKey);
if (ruleGoneOnB)
{
break;
}
await Task.Delay(TimeSpan.FromSeconds(1));
}
sw.Stop();
Console.WriteLine($" site-b policy tree caught up: {ruleGoneOnB} (after {sw.Elapsed.TotalSeconds:F0}s)");
Console.WriteLine($" site-b live gate already denies alice: {gateDenies}");
Console.WriteLine();
Console.WriteLine(ruleGoneOnB
? "[OK] the revoke authored on site-a converged onto site-b via system-tree replication."
: "[FAIL] the revoke did not converge onto site-b within the timeout.");
await appA.StopAsync();
await appB.StopAsync();
return ruleGoneOnB ? 0 : 1;
// --- helpers ---------------------------------------------------------------
// Runs an action under the ambient credential for the given subject. The
// credential flows to the grain on the Orleans request context; the membership
// context resolves it into a subject (with directory-expanded groups).
static async Task<T> AsAsync<T>(string subject, Func<Task<T>> action)
{
using (LatticeCredentialContext.Use(subject, scheme: Scheme))
{
return await action();
}
}
// True when the subject is currently allowed to write the key (probe, no throw).
static async Task<bool> CanAsync(ILattice tree, string subject, string key) =>
await AsAsync(subject, async () =>
{
try
{
await tree.SetAsync(key, Encoding.UTF8.GetBytes("probe"));
return true;
}
catch (LatticeAuthorizationDeniedException)
{
return false;
}
});
// "allowed" / "DENIED" for a write attempt.
static async Task<string> WriteOutcome(ILattice tree, string subject, string key, string value) =>
await AsAsync(subject, async () =>
{
try
{
await tree.SetAsync(key, Encoding.UTF8.GetBytes(value));
return "allowed";
}
catch (LatticeAuthorizationDeniedException)
{
return "DENIED";
}
});
// "allowed" / "DENIED" for a delete attempt.
static async Task<string> DeleteOutcome(ILattice tree, string subject, string key) =>
await AsAsync(subject, async () =>
{
try
{
await tree.DeleteAsync(key);
return "allowed";
}
catch (LatticeAuthorizationDeniedException)
{
return "DENIED";
}
});
// The stored value for a read, or "(hidden)" when read is soft-denied.
static async Task<string> ReadOutcome(ILattice tree, string subject, string key) =>
await AsAsync(subject, async () =>
{
var value = await tree.GetAsync(key);
return value is null ? "(hidden)" : $"'{Encoding.UTF8.GetString(value)}'";
});
// Number of keys a subject can see via an authorized range read.
static async Task<int> RangeCount(ILattice tree, string subject) =>
await AsAsync(subject, async () =>
{
var cursorId = await tree.OpenKeyCursorAsync();
var count = 0;
try
{
while (true)
{
var page = await tree.NextKeysAsync(cursorId, 100);
count += page.Keys.Count;
if (!page.HasMore)
{
break;
}
}
}
finally
{
await tree.CloseCursorAsync(cursorId);
}
return count;
});
// Polls the predicate until it is true or the budget elapses.
static async Task<bool> WaitUntilAsync(Func<Task<bool>> predicate, TimeSpan budget)
{
var deadline = DateTime.UtcNow + budget;
while (DateTime.UtcNow < deadline)
{
if (await predicate())
{
return true;
}
await Task.Delay(TimeSpan.FromMilliseconds(500));
}
return await predicate();
}
CrossClusterAuthorization.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Orleans.Lattice.Samples.CrossClusterAuthorization</RootNamespace>
<AssemblyName>Orleans.Lattice.Samples.CrossClusterAuthorization</AssemblyName>
</PropertyGroup>
<!-- The reserved membership/auth system trees converge across sites over the
gRPC replication transport, which is served on an ASP.NET Core / Kestrel
HTTP/2 pipeline. Like CrossClusterReplication this sample hosts a web app
per site rather than a bare generic host. -->
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Orleans.Server" Version="10.2.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\lattice\Orleans.Lattice.csproj" />
<ProjectReference Include="..\..\src\lattice.membership\Orleans.Lattice.Membership.csproj" />
<ProjectReference Include="..\..\src\lattice.auth\Orleans.Lattice.Auth.csproj" />
<ProjectReference Include="..\..\src\lattice.replication\Orleans.Lattice.Replication.csproj" />
<ProjectReference Include="..\..\src\lattice.replication.grpc\Orleans.Lattice.Replication.Grpc.csproj" />
</ItemGroup>
</Project>
DemoAuthenticator.cs
using Orleans.Lattice.Membership;
namespace Orleans.Lattice.Samples.CrossClusterAuthorization;
/// <summary>
/// A minimal demo <see cref="ILatticeCredentialAuthenticator"/> that trusts the
/// ambient credential's token as the caller subject id. It handles only
/// credentials stamped with <see cref="Scheme"/>, so it never shadows the
/// built-in anonymous authenticator for an unstamped (system-origin) turn.
///
/// A real deployment resolves the subject from a validated JWT or Entra token
/// (see the JWT / Entra authenticators shipped with the Membership package); this
/// sample uses a trivial trusted-token authenticator so the whole flow runs on
/// loopback with no identity provider. Group membership is not asserted here -
/// the membership directory expands each subject's groups from the user/group
/// edges seeded at startup, so the sample demonstrates directory-driven groups.
/// </summary>
internal sealed class DemoAuthenticator : ILatticeCredentialAuthenticator
{
/// <summary>The scheme hint this authenticator claims.</summary>
public const string Scheme = "demo-scheme";
/// <summary>The issuer stamped on the resolved principal.</summary>
public const string Issuer = "https://issuer.cross-cluster-authorization.sample/";
/// <inheritdoc />
public bool CanHandle(in LatticeCredential credential) =>
string.Equals(credential.Scheme, Scheme, StringComparison.Ordinal);
/// <inheritdoc />
public ValueTask<LatticePrincipal?> AuthenticateAsync(
LatticeCredential credential,
CancellationToken cancellationToken = default) =>
new(new LatticePrincipal(credential.Token, Issuer));
}
SiteConfig.cs
namespace Orleans.Lattice.Samples.CrossClusterAuthorization;
/// <summary>
/// Immutable description of one in-process Orleans cluster ("site") in the
/// cross-cluster authorization topology: its cluster id, its Orleans silo/gateway ports,
/// the local Kestrel port that serves its inbound replication gRPC endpoint, and
/// the single peer it ships the reserved membership/auth system trees to.
/// </summary>
internal sealed record SiteConfig(
string ClusterId,
int SiloPort,
int GatewayPort,
int GrpcPort,
string PeerClusterId,
int PeerGrpcPort);
SiteFactory.cs
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Orleans.Lattice.Auth;
using Orleans.Lattice.Membership;
using Orleans.Lattice.Replication;
using Orleans.Lattice.Replication.Grpc;
namespace Orleans.Lattice.Samples.CrossClusterAuthorization;
/// <summary>
/// Builds one in-process Orleans cluster wired with membership, auth enforcement,
/// and cross-cluster replication of the data tree plus the reserved membership/auth
/// system trees. Two of these, given
/// mirror-image <see cref="SiteConfig"/>s, form a two-site topology whose policy
/// and membership surface converges across sites, so a revoke authored on one
/// site becomes enforced on the other.
/// </summary>
internal static class SiteFactory
{
/// <summary>The single data tree both sites replicate, merged last-writer-wins.</summary>
public const string TreeName = "production-line";
public static WebApplication Build(SiteConfig site)
{
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.Logging.SetMinimumLevel(LogLevel.None);
// Serve the inbound replication gRPC endpoint as plaintext HTTP/2 (h2c)
// on this site's local port; clear the ASP.NET default URLs so the two
// sites do not collide on them.
builder.WebHost.UseSetting(WebHostDefaults.ServerUrlsKey, string.Empty);
builder.WebHost.ConfigureKestrel(k =>
k.ListenLocalhost(site.GrpcPort, o => o.Protocols = HttpProtocols.Http2));
builder.Host.UseOrleans(silo =>
{
// Each site is its own Orleans cluster: distinct ClusterId and ports
// so both run in one process.
silo.UseLocalhostClustering(
siloPort: site.SiloPort,
gatewayPort: site.GatewayPort,
serviceId: "cross-cluster-authorization-sample",
clusterId: site.ClusterId);
silo.AddMemoryGrainStorageAsDefault();
silo.UseInMemoryReminderService();
silo.AddLattice((services, name) => services.AddMemoryGrainStorage(name));
// Membership resolves the ambient caller credential into a subject
// whose groups are expanded from the directory's user/group edges.
silo.AddLatticeMembership();
// Auth installs the enforcement gate. Default-deny: only explicit
// allow rules grant access. "root-admin" is a bootstrap administrator
// so the sample can seed the tree's data keys before any rule grants
// a write. (Groups and rules need no grant: the membership directory
// and the policy store write their reserved trees under system
// origin.)
silo.AddLatticeAuth(options =>
{
options.DefaultEffect = LatticeEffect.Deny;
options.BootstrapAdministrators.Add("root-admin");
});
// Replicate the data tree last-writer-wins so a write on one site
// converges on the other.
silo.AddLatticeReplication(opts =>
{
opts.ClusterId = site.ClusterId;
opts.ReplicatedTrees = new Dictionary<string, LatticeMergeMode>(StringComparer.Ordinal)
{
[TreeName] = LatticeMergeMode.LwwRegister,
};
opts.ReplicationPeers = new[] { site.PeerClusterId };
});
// The system-tree replication special case: enrol the reserved
// membership + authorization-policy trees into replication so the
// identity and policy surface converges across sites. This is what
// makes a revoke authored on one site become enforced on the other.
silo.ReplicateLatticeSystemTrees();
// The trusted-token authenticator that maps the ambient credential's
// token to the caller subject id (a real deployment uses JWT/Entra).
silo.Services.AddSingleton<ILatticeCredentialAuthenticator, DemoAuthenticator>();
});
// Cross-cluster gRPC binding to the peer's h2c endpoint.
builder.Services.AddLatticeReplicationGrpc(opts =>
{
opts.Peers[site.PeerClusterId] = new Uri($"http://localhost:{site.PeerGrpcPort}");
opts.AllowPlaintextEndpoints = true;
opts.LocalClusterId = site.ClusterId;
});
// Loopback dev sample with no shared secret: turn off the receiver-side
// shared-secret authenticator (production must supply a secret).
builder.Services.Configure<LatticeReplicationSecurityOptions>(o =>
o.RequireAuthentication = false);
var app = builder.Build();
// Map the inbound replication routes so the peer can ship batches here.
app.MapLatticeReplicationGrpc();
return app;
}
}