Customizing Data Isolation
Warning
This advanced feature may compromise strict data isolation between tenants. Use it only with full awareness, after carefully assessing potential risks to data security and privacy.
Default Behavior
In shared database mode, data isolation is enforced using a tenant identifier column in each table containing tenant-specific data. By default, this column is named TenantId.
- Read operations: The tenant identifier is automatically added to the
WHEREclause of SQL queries to filter data for the current tenant. - Write operations: The tenant identifier is automatically included in
INSERTandUPDATEstatements to ensure data is written for the current tenant.
Customizing Read Behavior in Shared Database Mode
You can customize how data is filtered for the current tenant by modifying the condition used for data isolation. To enable this for a specific data table:
- In Neos Studio, check the
Allow data isolation customizationswitch in the table configuration. - Implement a custom data isolation provider by creating a class that implements the
ICustomizingTenantDataIsolationinterface.
Note
Custom data isolation is applied only to tables that have the Allow data isolation customization option enabled. Otherwise, the default data isolation behavior is used.
Example: Custom Data Isolation Provider
The following example demonstrates a custom data isolation provider that filters data based on a list of authorized tenants, retrieved from the additional properties of the current tenant info. Access to data for multiple tenants is allowed only for internal service invocations (e.g., calls from another cluster using the IRemoteServiceInvoker interface).
Note
This example uses a Neos application named ProductManagement.
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Net;
using GroupeIsa.Neos.Shared.Infrastructure;
using GroupeIsa.Neos.Shared.Logging;
using GroupeIsa.Neos.Shared.MultiTenant;
using Microsoft.AspNetCore.Http;
namespace ProductManagement.Core.Application
{
public class CustomizingTenantDataIsolation : ICustomizingTenantDataIsolation
{
private readonly INeosTenantInfoAccessor _neosTenantInfoAccessor;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly INeosLogger<CustomizingTenantDataIsolation> _logger;
public CustomizingTenantDataIsolation(
INeosTenantInfoAccessor neosTenantInfoAccessor,
IHttpContextAccessor httpContextAccessor,
INeosLogger<CustomizingTenantDataIsolation> logger)
{
_neosTenantInfoAccessor = neosTenantInfoAccessor;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public Expression<Func<NeosTenantInfo, string, bool>>? TenantQueryFilter
{
get
{
if (!IsLocalCall())
{
// If the call is not local, do not apply custom tenant data isolation.
_logger.LogDebug("CustomizingTenantDataIsolation: Not a local call, no tenant data isolation applied.");
return null;
}
if (_neosTenantInfoAccessor.NeosTenantInfo?.AdditionalProperties.GetStrings("AuthorizedTenants") == null)
{
// If no tenants are specified, the filter is not applied, the data isolation is not customized.
return null;
}
// EF expression to filter data based on the authorized tenants of the tenant of the query.
// It reads them from its tenant parameter and captures nothing.
return (tenant, tenantId) => tenant.AdditionalProperties.GetStrings("AuthorizedTenants")!.Contains(tenantId);
}
}
private bool IsLocalCall()
{
IPAddress? remoteIp = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress;
return remoteIp != null && remoteIp.IsPrivate();
}
}
}
Note
The example above applies custom data isolation only if the call is local (from another pod in the same Kubernetes cluster). This prevents access to data from external networks.
Writing the tenant query filter
The TenantQueryFilter property returns an expression with two parameters:
- The first one is the
NeosTenantInfoof the tenant that runs the query. - The second one is the tenant identifier of the row, the value of its
TenantIdcolumn.
Entity Framework stores the filter in its model, which is built once and shared by all the tenants of the process. Neos replaces the first parameter with the tenant of the database context, which Entity Framework evaluates again for each query. The filter therefore applies the authorized tenants of the tenant that runs each query, and a change of its additional properties is taken into account without a restart.
Warning
The filter must read everything that depends on the tenant from its first parameter. A captured variable, field or property would keep the value of the tenant that built the model, for every tenant. Neos rejects such a filter: the model build fails with an InvalidOperationException that names the captured value. Only literals and static read-only fields of simple types (string.Empty, for example) are allowed.
// Correct: the authorized tenants are read from the tenant of each query.
return (tenant, tenantId) => tenant.AdditionalProperties.GetStrings("AuthorizedTenants")!.Contains(tenantId);
// Rejected: the captured array would be the one of the tenant that built the model.
string[] authorizedTenants = _neosTenantInfoAccessor.NeosTenantInfo!.AdditionalProperties.GetStrings("AuthorizedTenants")!;
return (tenant, tenantId) => authorizedTenants.Contains(tenantId);
The property itself can depend on the current request: returning null keeps the default data isolation for that request, as the IsLocalCall check of the example does. A query whose tenant has no authorized tenants must also return null, because the filter would otherwise call Contains on a null array.
Registering the Custom Data Isolation Provider
Register your custom data isolation provider in the dependency injection container using the AddCustomizingTenantDataIsolation extension method.
Startup.cs file of the business assembly:
using System.Diagnostics.CodeAnalysis;
using GroupeIsa.Neos.Shared.MultiTenant;
using Microsoft.Extensions.DependencyInjection;
namespace ProductManagement.Core.Application
{
/// <summary>
/// Represents the assembly startup.
/// </summary>
/// <remarks>
/// This class is automatically instantiated when the assembly is loaded.
/// </remarks>
[ExcludeFromCodeCoverage]
public static class Startup
{
/// <summary>
/// Configures services for dependency injection.
/// </summary>
/// <param name="services">The services collection.</param>
/// <remarks>
/// This method is automatically called when the assembly is loaded.
/// </remarks>
public static void ConfigureServices(IServiceCollection services)
{
services.AddCustomizingTenantDataIsolation<CustomizingTenantDataIsolation>();
}
}
}
Returning the tenantId property
You may want to include the tenant identifier in the GET API responses. To do so, you must explicitly include the TenantId property in your entity.
The type of the TenantId property must be string in the entity even if the actual type in the database is integer.
For this reason, you must use a converter to convert between the string type in the entity and the integer type in the database.
Add the following in your cluster configuration file (e.g., technicaldemos.yml):
PersistenceConverters:
- Name: StringToIntegerConverter
Type: GroupeIsa.Neos.Persistence.EntityFramework.Converters.StringToIntegerConverter
EntityPropertyDataType: String
DataColumnDataType: Integer
Then select the converter StringToIntegerConverter in the TenantId property of your entity in Neos Studio.
How It Works
The expression returned by the TenantQueryFilter property replaces the Finbuckle Multitenant Entity Framework global filter.
Neos keeps one Entity Framework model for the default data isolation and one for each distinct tenant query filter. Returning the same expression for all the tenants, as in the example, keeps a single customized model.
Note
If a global filter is already defined for the entity, it will be preserved and combined with the custom multi-tenant filter using an AND operator.