When you add embedded reporting to a multi-tenant SaaS product, row-level security isn't optional — it's the whole ballgame. One misconfigured RLS policy and you're serving Tenant A's data to Tenant B. In a B2B context, that's a trust violation that ends customer relationships.
Here's a practical breakdown of RLS patterns for embedded reporting in ASP.NET Core, including the vulnerability that catches most teams.
The Critical Rule
Tenant ID must come from the server-side authenticated session. Always.
This pattern is wrong:
// DON'T DO THIS
reportTool.init({
tenantId: document.getElementById('tenantId').value
});
A malicious user changes that value. You have a cross-tenant data breach.
This pattern is correct:
// Server-side — user cannot tamper with this
public override Task<DotNetReportUser> GetCurrentUser()
{
return Task.FromResult(new DotNetReportUser
{
ClientId = HttpContext.User.FindFirstValue("TenantId"),
IsAdmin = User.IsInRole("ReportAdmin")
});
}
Two Layers of RLS
Effective RLS in reporting requires:
Layer 1: Tenant isolation — Company A never sees Company B's data, even a single row, even in aggregates.
Layer 2: User-level scoping — Within Tenant A, a regional manager only sees their region's records.
Most implementations get Layer 1 right but skip Layer 2 for "later." Later often doesn't come until a customer notices a regional manager can see another region's data.
Defense in Depth with SQL Server RLS
Application-layer RLS is necessary but not sufficient. Add SQL Server's native Row-Level Security as a backstop:
CREATE FUNCTION dbo.fn_tenantPredicate(@TenantId INT)
RETURNS TABLE WITH SCHEMABINDING AS
RETURN SELECT 1 AS result
WHERE @TenantId = CAST(SESSION_CONTEXT(N'TenantId') AS INT);
CREATE SECURITY POLICY dbo.TenantPolicy
ADD FILTER PREDICATE dbo.fn_tenantPredicate(TenantId) ON dbo.Orders
WITH (STATE = ON);
Set the session context in an EF Core interceptor:
public class TenantRlsInterceptor : DbCommandInterceptor
{
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
SetTenantContext(command.Connection);
return base.ReaderExecuting(command, eventData, result);
}
private void SetTenantContext(DbConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "EXEC sp_set_session_context @key=N'TenantId', @value=@t";
cmd.Parameters.Add(new SqlParameter("@t", _tenantService.GetCurrentTenantId()));
cmd.ExecuteNonQuery();
}
}
Test Your RLS
Write dedicated cross-tenant isolation tests — not just happy-path functional tests:
[Fact]
public async Task Report_ShouldNotReturnCrossTenantData()
{
await SeedData("tenant-a", recordCount: 50);
await SeedData("tenant-b", recordCount: 30);
var user = CreateTestUser(tenantId: "tenant-a");
var results = await _reportService.RunReportAsync(testReportId, user);
// Must only return tenant-a's records
Assert.All(results, row => Assert.Equal("tenant-a", row["TenantId"]));
Assert.Equal(50, results.Count);
}
The Full Writeup
This is a condensed version — the full article covers user-level scoping patterns, the common RLS vulnerabilities (cached results without tenant keys, inconsistent table coverage, aggregate leakage), and a complete comparison table of approaches:
👉 https://dotnetreport.com/blogs/row-level-security-embedded-reporting-net/
Building embedded reporting into a .NET SaaS product? Happy to answer questions in the comments.
Built with Dotnet Report — embedded self-service reporting for multi-tenant ASP.NET Core applications.
Top comments (0)