---
title: "CrossClusterAuthorization source"
url: "https://nsta1.github.io/Orleans.Lattice/samples/CrossClusterAuthorization/source.html"
source: "https://github.com/NSTA1/Orleans.Lattice/tree/release/9.9/samples/CrossClusterAuthorization"
documents: "Orleans.Lattice 9.9.0 (release line 9.9)"
built: "2026-10-04"
all-pages: "https://nsta1.github.io/Orleans.Lattice/llms.txt"
---
# CrossClusterAuthorization source

Part of [Cross-Cluster Authorization](README.md).

The source of the [CrossClusterAuthorization](https://github.com/NSTA1/Orleans.Lattice/tree/release/9.9/samples/CrossClusterAuthorization) sample.

## Program.cs

````csharp
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

````xml
<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

````csharp
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

````csharp
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

````csharp
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;
    }
}
````
