Home

Awesome

<img src="img/CSharp-Toolkit-Icon.png" alt="Backend Toolkit" width="64px" />Orleans.Multitenant

Secure, flexible tenant separation for Microsoft Orleans 8

Nuget (with prereleases)<br /> (install in silo client and grain implementation projects)

Summary

Microsoft Orleans 8 is a great technology for building distributed, cloud-native applications. It was designed to reduce the complexity of building this type of applications for C# developers.

However, creating multi tenant applications with Orleans out of the box requires careful design, complex coding and significant testing to prevent unintentional leakage of communication or stored data across tenants. Orleans.Multitenant adds this capability to Orleans for free, as an uncomplicated, flexible and extensible API that lets developers:

Scope and limitations

Usage

All multitenant features can be independenty enabled and configured at silo startup, with the ISiloBuilder AddMultitenant* extension methods. See the inline documentation for more details on how to use the API's that are mentioned in this readme. All the public API's come with full inline documentation

Add multitenant storage

To add tenant storage separation to any Orleans storage provider, use AddMultitenantGrainStorage and AddMultitenantGrainStorageAsDefault on an ISiloBuilder or IServiceCollection:

siloBuilder
.AddMultitenantGrainStorageAsDefault<AzureTableGrainStorage, AzureTableStorageOptions, AzureTableGrainStorageOptionsValidator>(
    (silo, name) => silo.AddAzureTableGrainStorage(name, options =>
        options.ConfigureTableServiceClient(tableStorageConnectionString)),
        // Called during silo startup, to ensure that any common dependencies
        // needed for tenant-specific provider instances are initialized

    configureTenantOptions: (options, tenantId) => {
        options.ConfigureTableServiceClient(tableStorageConnectionString);
        options.TableName = $"OrleansGrainState{tenantId}";
    }   // Called on the first grain state access for a tenant in a silo,
        // to initialize the options for the tenant-specific provider instance
        // just before it is instantiated
 )

Customize storage provider constructor parameters

By default, the parameters passed into the storage provider instance for a tenant are the tenant provider name (which contains the tenant Id) and the tenant options. Some storage providers may expect a different (wrapper) type for the options, or you may want to pass in additional parameters (e.g. ClusterOptions).

To do this, you can pass in an optional GrainStorageProviderParametersFactory<TGrainStorageOptions>? getProviderParameters parameter.

E.g. the Orleans ADO.NET storage provider constructor expects an IOptions<AdoNetGrainStorageOptions> instead of an AdoNetGrainStorageOptions. You can use getProviderParameters to wrap the AdoNetGrainStorageOptions in an IOptions<AdoNetGrainStorageOptions>:

.AddMultitenantGrainStorageAsDefault<AdoNetGrainStorage, AdoNetGrainStorageOptions, AdoNetGrainStorageOptionsValidator>(
    (silo, name) => silo.AddAdoNetGrainStorage(name, options => options.ConnectionString = sqlConnectionString),

    configureTenantOptions: (options, tenantId) => options.ConnectionString = sqlConnectionString.Replace("[DatabaseName]", tenantId, StringComparison.Ordinal),

    getProviderParameters: (services, providerName, tenantProviderName, options) => [Options.Create(options)]
)

Note that you do not need to include the tenantProviderName in the returned provider parameters; it is added automatically.

The parameters passed to getProviderParameters allow to access relevant services from DI to retrieve additional provider parameters, if needed.

Add multitenant streams

To configure a silo to use a specific stream provider type as a named stream provider with tenant separation, use AddMultitenantStreams. Any Orleans stream provider can be used:

.AddMultitenantStreams(
    "provider_name", (silo, name) => silo
    .AddMemoryStreams<DefaultMemoryMessageBodySerializer>(name)
    .AddMemoryGrainStorage(name)
 )

Both implicit and explicit stream subscriptions are supported.

Add multitenant communication separation

To configure a silo to use tenant separation for grain communication, use AddMultitenantCommunicationSeparation . Separation will be enforced for both grain calls and streams (the latter if used together with AddMultitenantStreams)

Optionally pass in an ICrossTenantAuthorizer factory and/or an IGrainCallTenantSeparator factory, to control which tenants are authorized to communicate, and which grain calls require authorization:

.AddMultitenantCommunicationSeparation(_ => new ExtendedCrossTenantAccessAuthorizer())
class ExtendedCrossTenantAccessAuthorizer : ICrossTenantAuthorizer
{
    internal const string RootTenantId = "RootTenant";

    public bool IsAccessAuthorized(string? sourceTenantId, string? targetTenantId)
    =>  string.CompareOrdinal(sourceTenantId, RootTenantId) == 0;
    // Allow access from the root tenant to any tenant
}

By default different tenants are not authorized to communicate, and only calls to Orleans.* grain interfaces are exempted from authorization

Access tenant grains and streams from a tenant grain

Where a tenant grain is available,

When AddMultitenantCommunicationSeparation is used, all of the above methods are guarded against unautorized access.

Access tenant grains and streams without a tenant grain

Where no tenant grain is available (e.g. in a cluster client, a stateless worker grain or a grain service),

Note that guarding against unauthorized tenant access that is not initiated from a tenant grain (e.g. when using a cluster client in an ASP.NET controller, or in a stateless worker grain or a grain service) is the responsibility of the application developer, since what constitutes a tenant context there is application specific

Grain/stream key and tenant id

Tenant id's are stored in the key of a tenant specific GrainId / StreamId. Use these methods to access the individual parts of the key:

string? GetTenantId(this IAddressable grain);
string  GetKeyWithinTenant(this IAddressable grain);

string? GetTenantId(this GrainId grainId);
string GetKeyWithinTenant(this GrainId grainId);

string? GetTenantId(this StreamId streamId);
string  GetKeyWithinTenant(this StreamId streamId);

The null tenant

Note that a tenant id with value null means that a grain was not created with the tenant aware API's as described in this readme. This could e.g. be the case when 3rd party code is responsible for creating the grain keys.

Even though the null tenant cannot be specified in the tenant aware API's, it is a valid tenant Id value in the parameters of the ICrossTenantAuthorizer.IsAccessAuthorized callback. This enables support for scenario's like above.

To access null tenant grains, use the Orleans built-in IGrainFactory, and register an ICrossTenantAuthorizer that allows access between the null tenant and other tenants. You can also exclude specific interface namespaces from the need to be authorized by registering an IGrainCallTenantSeparator (see Add multitenant communication separation).

The MultitenantStorageOptions.TenantIdForNullTenant setting specifies the non-null string value representing the null tenant. This value is passed as the tenantId parameter of the configureTenantOptions action, which can be specified in AddMultitenantGrainStorage methods. This setting allows developers to choose a name for the null tenant in storage that does not conflict with other valid tenant names in the application.

Tenant unaware streams

To access tenant unaware streams (e.g. streams whose keys are defined by 3rd party code), use the Orleans built-in IStreamProvider. There is no need for an ICrossTenantAuthorizer to enable this access, because an IStreamProvider does not have the TenantSeparatingStreamFilter attached.