MultiTenancy 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 MultiTenancy sample.
Program.cs
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Orleans.Hosting;
using Orleans.Lattice;
using Orleans.Lattice.Api.TenantAdmin;
using Orleans.Lattice.Auth;
using Orleans.Lattice.Membership;
using Orleans.Lattice.Samples.MultiTenancy;
using Orleans.Lattice.Tenancy;
// ---------------------------------------------------------------------------
// MultiTenancy - the opt-in tenancy layer on a single silo.
//
// One in-process Orleans silo runs the full control-plane stack: Membership
// (identity) + Auth (a fail-closed gate) + Tenancy (the tenant registry and
// isolation seams) + the Tenant-Admin API facade. With none of these packages
// referenced the core tree is byte-for-byte unchanged; adding them turns on
// multi-tenancy without editing a line of core.
//
// The sample walks four acts:
//
// 1. Tenant tree naming. A tenant-scoped tree id self-describes its owner:
// LatticeTenantTrees.Compose(tenant, name) -> "t/{tenant}/{name}", and
// TryGetTenant reverses it. This structural prefix is what the isolation
// gate checks - a caller in tenant "acme" can only name "t/acme/*"
// unless another tenant grants it access.
// 2. Tenant lifecycle as a platform operator. A bootstrap administrator
// creates two tenants and reads back their lifecycle status.
// 3. Lifecycle transitions and guards. Suspend / resume a tenant, delete a
// tenant (cascading its trees), and prove create is not upsert - a second
// create of the same id is refused (TenantAlreadyExistsException).
// 4. Fail-closed control plane. The reserved "default" tenant can never be
// deleted or suspended (ReservedTenantOperationException), and a caller
// who is not a platform operator is denied every lifecycle op
// (LatticeAuthorizationDeniedException) under the default-deny gate.
// ---------------------------------------------------------------------------
const string Scheme = DemoAuthenticator.Scheme;
using var host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.SetMinimumLevel(LogLevel.None);
})
.UseOrleans(silo =>
{
silo.UseLocalhostClustering();
silo.AddMemoryGrainStorageAsDefault();
silo.UseInMemoryReminderService();
silo.AddLattice((services, name) => services.AddMemoryGrainStorage(name));
// Membership resolves the ambient caller credential into a subject.
silo.AddLatticeMembership();
// Auth installs the enforcement gate. Default-deny (the production
// posture): tenant-lifecycle operations authorize Admin on the reserved
// authorization policy tree, which only a bootstrap administrator (or a
// subject explicitly granted whole-tree Admin on that tree through the
// access-administration delegation) holds - a cluster-wide all-trees rule
// does not reach it - so the operator seam is fail-closed against every
// other caller.
silo.AddLatticeAuth(options =>
{
options.DefaultEffect = LatticeEffect.Deny;
options.BootstrapAdministrators.Add("platform-operator");
});
// Tenancy turns on the tenant registry, isolation seams, and the default
// tenant. The Tenant-Admin API adds the operator control-plane facade.
silo.AddLatticeTenancy();
silo.AddLatticeTenantAdminApi();
silo.Services.AddSingleton<ILatticeCredentialAuthenticator, DemoAuthenticator>();
})
.Build();
Console.Write("Silo starting...");
await host.StartAsync();
Console.WriteLine(" ready.\n");
var admin = host.Services.GetRequiredService<ILatticeTenantAdmin>();
// -- Act 1: tenant tree naming ----------------------------------------------
Console.WriteLine("== Act 1: a tenant-scoped tree id self-describes its owner ==");
var acme = TenantId.Parse("acme");
var composed = LatticeTenantTrees.Compose(acme, "catalog");
Console.WriteLine($" Compose(acme, 'catalog') -> '{composed}'");
Console.WriteLine($" IsTenantScoped('{composed}') -> {LatticeTenantTrees.IsTenantScoped(composed)}");
Console.WriteLine(
LatticeTenantTrees.TryGetTenant(composed, out var owner)
? $" TryGetTenant('{composed}') -> '{owner}' (isolation gate checks this prefix)\n"
: " TryGetTenant failed\n");
// -- Act 2: create tenants as a platform operator ---------------------------
Console.WriteLine("== Act 2: create tenants as a platform operator ==");
TenantCreationResult acmeCreated;
TenantCreationResult globexCreated;
using (LatticeCredentialContext.Use("platform-operator", scheme: Scheme))
{
// Hand 'acme' to its delegated admins explicitly; let 'globex' default, which
// seeds the calling operator so the creator can still see what it created.
acmeCreated = await admin.CreateTenantAsync("acme", ["acme-admin"]);
globexCreated = await admin.CreateTenantAsync("globex");
}
Console.WriteLine($" created '{acmeCreated.TenantId}' -> {acmeCreated.Status}, admins: {string.Join(", ", acmeCreated.AdminSubjects)}");
Console.WriteLine($" created '{globexCreated.TenantId}' -> {globexCreated.Status}, admins: {string.Join(", ", globexCreated.AdminSubjects)}\n");
// -- Act 3: lifecycle transitions and guards --------------------------------
Console.WriteLine("== Act 3: lifecycle transitions and guards ==");
using (LatticeCredentialContext.Use("platform-operator", scheme: Scheme))
{
var suspended = await admin.SuspendTenantAsync("acme");
Console.WriteLine($" suspend 'acme' -> {suspended.PreviousStatus} => {suspended.NewStatus} (changed: {suspended.Changed})");
var resumed = await admin.ResumeTenantAsync("acme");
Console.WriteLine($" resume 'acme' -> {resumed.PreviousStatus} => {resumed.NewStatus} (changed: {resumed.Changed})");
var deleted = await admin.DeleteTenantAsync("globex");
Console.WriteLine($" delete 'globex' -> removed, {deleted.CascadedTreeCount} tree(s) cascaded");
// Create is not upsert: a second create of the same id is refused.
try
{
await admin.CreateTenantAsync("acme");
Console.WriteLine(" re-create 'acme' -> UNEXPECTEDLY allowed");
}
catch (TenantAlreadyExistsException)
{
Console.WriteLine(" re-create 'acme' -> refused (TenantAlreadyExistsException)");
}
}
Console.WriteLine();
// -- Act 4: fail-closed control plane ---------------------------------------
Console.WriteLine("== Act 4: fail-closed control plane ==");
var reservedProtected = false;
using (LatticeCredentialContext.Use("platform-operator", scheme: Scheme))
{
try
{
await admin.DeleteTenantAsync(TenantId.DefaultId);
Console.WriteLine(" delete reserved 'default' -> UNEXPECTEDLY allowed");
}
catch (ReservedTenantOperationException)
{
Console.WriteLine(" delete reserved 'default' -> refused (ReservedTenantOperationException)");
reservedProtected = true;
}
}
var operatorGated = false;
using (LatticeCredentialContext.Use("mallory", scheme: Scheme))
{
try
{
await admin.CreateTenantAsync("mallory-corp");
Console.WriteLine(" create as non-operator 'mallory' -> UNEXPECTEDLY allowed");
}
catch (LatticeAuthorizationDeniedException)
{
Console.WriteLine(" create as non-operator 'mallory' -> denied (LatticeAuthorizationDeniedException)");
operatorGated = true;
}
}
Console.WriteLine();
var ok = reservedProtected && operatorGated;
Console.WriteLine(ok
? "[OK] tenant lifecycle ran end-to-end; the reserved tenant and the operator seam stayed fail-closed."
: "[FAIL] a control-plane guard did not hold.");
await host.StopAsync();
return ok ? 0 : 1;
DemoAuthenticator.cs
using Orleans.Lattice.Membership;
namespace Orleans.Lattice.Samples.MultiTenancy;
/// <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
/// one silo with no identity provider.
/// </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.multitenancy.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));
}
MultiTenancy.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.MultiTenancy</RootNamespace>
<AssemblyName>Orleans.Lattice.Samples.MultiTenancy</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.11" />
<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.tenancy\Orleans.Lattice.Tenancy.csproj" />
<ProjectReference Include="..\..\src\lattice.api.tenantadmin\Orleans.Lattice.Api.TenantAdmin.csproj" />
<ProjectReference Include="..\..\src\lattice.api.abstractions\Orleans.Lattice.Api.Abstractions.csproj" />
</ItemGroup>
</Project>