DEV Community

Dotnet Report
Dotnet Report

Posted on • Originally published at dotnetreport.com

Row-Level Security in Embedded Reporting: The Patterns That Actually Work for .NET SaaS

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
});
Enter fullscreen mode Exit fullscreen mode

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")
    });
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)