<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Razi Syed</title>
    <description>The latest articles on DEV Community by Razi Syed (@razisyed).</description>
    <link>https://dev.to/razisyed</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4139957%2Fc36062f3-b42e-44a2-8829-02dd81a0f21c.png</url>
      <title>DEV Community: Razi Syed</title>
      <link>https://dev.to/razisyed</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/razisyed"/>
    <language>en</language>
    <item>
      <title>Role-based access to reports and folders in ASP.NET Core: users, roles and admin claims</title>
      <dc:creator>Razi Syed</dc:creator>
      <pubDate>Tue, 03 Nov 2026 14:14:10 +0000</pubDate>
      <link>https://dev.to/razisyed/role-based-access-to-reports-and-folders-in-aspnet-core-users-roles-and-admin-claims-13g2</link>
      <guid>https://dev.to/razisyed/role-based-access-to-reports-and-folders-in-aspnet-core-users-roles-and-admin-claims-13g2</guid>
      <description>&lt;p&gt;&lt;em&gt;By Razi Syed. Sample code: &lt;a href="https://github.com/dotnetreport/dotnetreport-multitenant-rls" rel="noopener noreferrer"&gt;github.com/dotnetreport/dotnetreport-multitenant-rls&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Once users can build and save their own reports, a second question shows up almost immediately: &lt;em&gt;who gets to see which report?&lt;/em&gt; Finance wants a "Payroll by department" report that the rest of the company never sees. A team lead wants to share one report with their team, read-only. Support wants a handful of people to be able to fix everybody's broken reports. And someone has to be able to change the reporting schema itself, which is a very different permission from "can edit a report".&lt;/p&gt;

&lt;p&gt;If you model all of that as one &lt;code&gt;IsAdmin&lt;/code&gt; flag, you end up either locking users out of the thing you built for them, or handing everyone the keys. This article walks through how I separate those concerns in ASP.NET Core using the role and claim hooks in &lt;a href="https://dotnetreport.com" rel="noopener noreferrer"&gt;Dotnet Report&lt;/a&gt; (which I work on — disclosure). The pattern carries over even if you build your own report builder: &lt;strong&gt;identity comes from the server, permissions are data on each report and folder, and admin powers are claims, checked on the server.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It builds on the previous two articles: &lt;a href="https://dev.to/razisyed/add-self-service-ad-hoc-reporting-to-an-aspnet-core-app-without-writing-every-report-yourself-44ng"&gt;adding the report builder&lt;/a&gt; and &lt;a href="https://dev.to/razisyed/multi-tenant-reporting-in-aspnet-core-row-level-security-your-users-cant-bypass-14lg"&gt;multi-tenant row-level security&lt;/a&gt;. Row-level security decides which &lt;em&gt;rows&lt;/em&gt; a user sees. This article is about which &lt;em&gt;reports, folders and dashboards&lt;/em&gt; they see, and what they can do with them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three separate layers
&lt;/h2&gt;

&lt;p&gt;It helps to name them before writing any code:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Who is this user?&lt;/strong&gt; User id, tenant id, and their roles. This comes from your auth cookie or token, never from the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What can they do with this report or folder?&lt;/strong&gt; Manage it, view it only, or delete it. Each one can be granted to specific users, specific roles, or a tenant. This is data saved with each report, folder and dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can they step outside the normal rules?&lt;/strong&gt; Admin mode (manage reports and dashboards for all users) and the setup screen (change the reporting schema). These are claims on the user, and they should be rare.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Layer 1: identity in &lt;code&gt;GetSettings()&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Everything the report builder does goes through one server-side method, &lt;code&gt;GetSettings()&lt;/code&gt; in &lt;code&gt;DotNetReportApiController&lt;/code&gt;. That's where you translate your auth into the reporting context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ClaimsPrincipal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;FindFirst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserId&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;FindFirst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NameIdentifier&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The roles this user has: drives role-based access to reports and folders&lt;/span&gt;
&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CurrentUserRole&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Claims&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Every role and user in the system: populates the sharing pickers&lt;/span&gt;
&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserRoles&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"Finance"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Sales"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Support"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Manager"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Users&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt; &lt;span class="c1"&gt;// e.g. { id = "u-42", text = "Jane Doe" }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two properties look similar and are easy to mix up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CurrentUserRole&lt;/code&gt; is the list of roles &lt;strong&gt;this user has&lt;/strong&gt;. It's what access is checked against.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UserRoles&lt;/code&gt; (and &lt;code&gt;Users&lt;/code&gt;) are &lt;strong&gt;every&lt;/strong&gt; role and user in your app. They only fill the dropdowns when someone shares a report. They grant nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The installed controller already reads &lt;code&gt;ClaimTypes.Role&lt;/code&gt; claims into &lt;code&gt;CurrentUserRole&lt;/code&gt; as a fallback, so if your login puts roles in the cookie you get this for free. Fill &lt;code&gt;UserRoles&lt;/code&gt; and &lt;code&gt;Users&lt;/code&gt; from your own user store. If you'd rather not do that in code, the users-and-roles setup lets you pick a different source (code, a SQL query or the portal).&lt;/p&gt;

&lt;p&gt;One trap here. If &lt;code&gt;UserId&lt;/code&gt; is empty &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;CurrentUserRole&lt;/code&gt; is empty, there's nothing to check access against, so the server-side access check returns early and everyone can see everything. That's fine on a dev box and wrong in production. The very first thing to check when "permissions don't work" is whether &lt;code&gt;GetSettings()&lt;/code&gt; actually returns a user id.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: permissions on reports and folders
&lt;/h2&gt;

&lt;p&gt;Every report, folder and dashboard carries its own access fields. In the UI they show up as access badges:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Permission&lt;/th&gt;
&lt;th&gt;By user&lt;/th&gt;
&lt;th&gt;By role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Manage (edit)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UserId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;UserRoles&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;View only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ViewOnlyUserId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ViewOnlyUserRoles&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DeleteOnlyUserId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DeleteOnlyUserRoles&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tenant&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ClientId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each field is a comma-separated list. An empty field means "not restricted on that axis", which is why the manage badges read "Any User" / "Any Role" when they're blank. So a report with &lt;code&gt;ViewOnlyUserRoles = "Finance"&lt;/code&gt; is readable by anyone in the Finance role, and a folder with &lt;code&gt;UserRoles = "Support"&lt;/code&gt; can be managed by Support.&lt;/p&gt;

&lt;p&gt;When an admin changes access from the &lt;em&gt;Manage Access&lt;/em&gt; dialog, the front-end saves exactly those fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/ReportApi/SaveReportAccess&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;reportJson&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reportId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;ClientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;UserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAsList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;ViewOnlyUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAsList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;viewOnlyUsers&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;DeleteOnlyUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAsList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deleteOnlyUsers&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;UserRoles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAsList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userRoles&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;ViewOnlyUserRoles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAsList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;viewOnlyUserRoles&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;DeleteOnlyUserRoles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAsList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deleteOnlyUserRoles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Folders save the same fields through &lt;code&gt;/ReportApi/SaveFolderData&lt;/code&gt;. Reports and folders also have a &lt;code&gt;ShowAdminOnly&lt;/code&gt; flag ("Hide from reports unless Admin Mode is enabled") that keeps them out of the normal list. It's good for work-in-progress reports, or helper reports that only exist to be used as drill-downs. Treat it as tidiness, though, not security. Use the access fields for anything sensitive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sensible defaults for new reports
&lt;/h3&gt;

&lt;p&gt;Most permission bugs are really defaults bugs. Someone creates a report, it's visible to the whole tenant, and nobody notices for a month. &lt;code&gt;GetUsersAndRoles()&lt;/code&gt; in the API controller has a block that sets the starting permissions for every new report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Applied by default to any new report a user creates. Leave blank to allow access to all.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;newReportClientId&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// comma separated client ids&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;newReportEditUserId&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// comma separated user ids with edit permission&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;newReportViewUserId&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// comma separated user ids with view permission&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;newReportEditUserRoles&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Manager"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// comma separated roles with edit permission&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;newReportViewUserRoles&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// comma separated roles with view permission&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The front-end also adds the current user to the edit list of a new report, so the creator never locks themselves out of their own work. A reasonable SaaS default: tenant set from &lt;code&gt;ClientId&lt;/code&gt;, the creator plus a "Manager" role can edit, and nobody else sees it until it's shared on purpose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where it's enforced
&lt;/h3&gt;

&lt;p&gt;Hiding a report in the list is a UI nicety. It isn't security. The part that matters is on the server. Every report API call goes through &lt;code&gt;CallReportApi&lt;/code&gt;, which builds the identity fields from &lt;code&gt;GetSettings()&lt;/code&gt; and not from the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;GetSettings&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserId&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"User context mismatch"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, for any call that names a report, folder or dashboard, it checks access with the reporting service before forwarding the call. That check uses the server-side user id, tenant and &lt;code&gt;CurrentUserRole&lt;/code&gt;, and throws if access is denied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;adminMode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dashboardId&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ValidateAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dashboardId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dashboardId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;folderId&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ValidateAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;folderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;folderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reportId&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ValidateAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reportId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reportId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So guessing a report id in the browser's dev tools doesn't get you that report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: admin mode and setup access are claims
&lt;/h2&gt;

&lt;p&gt;The two "step outside the rules" permissions are claim types defined in &lt;code&gt;ClaimsStore&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;AllowManageUsersAndRoles&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"AllowManageUsersAndRoles"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;AllowSetupPageAccess&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"AllowSetupPageAccess"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;AllowAdminMode&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"AllowAdminMode"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;AllowAdminMode&lt;/code&gt;&lt;/strong&gt; lets a user switch the report builder into admin mode, where the banner reads "You can manage Reports/Dashboards for all users". &lt;code&gt;GetSettings()&lt;/code&gt; turns the claim into a flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CanUseAdminMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ClaimsHelper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HasAnyRequiredClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ClaimsPrincipal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ClaimsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowAdminMode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The admin-mode toggle only renders when that flag is true. More importantly, the server doesn't trust the toggle. &lt;code&gt;CallReportApi&lt;/code&gt; drops an &lt;code&gt;adminMode&lt;/code&gt; value from the request unless &lt;code&gt;CanUseAdminMode&lt;/code&gt; is true, and a short list of account-level methods are refused outright for non-admins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The front end flag can be tampered with so check it here too&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;AdminOnlyApiMethods&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s"&gt;"/ReportApi/SaveReportHeader"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"/ReportApi/SaveFolderData"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"/ReportApi/DeleteFolder"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"/ReportApi/SaveReportAccess"&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CanUseAdminMode&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;AdminOnlyApiMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrdinalIgnoreCase&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;StatusCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"You are not authorized to perform this action"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice: ordinary users build and save reports within the permissions they've been given, while creating folders and changing access on existing reports are reserved for people with the claim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;AllowSetupPageAccess&lt;/code&gt;&lt;/strong&gt; guards the &lt;code&gt;/dotnetsetup&lt;/code&gt; screen, where you choose which tables and columns are exposed, rename fields, and set up joins. That's a schema-level permission, so it's on the controller rather than in the report data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DotNetSetupController&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Controller&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;CustomAuthorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowSetupPageAccess&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;databaseApiKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;View&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;CustomAuthorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowManageUsersAndRoles&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;UsersAndRoles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;databaseApiKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;View&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CustomAuthorize&lt;/code&gt; checks &lt;code&gt;user.HasClaim(c =&amp;gt; c.Type == requiredClaim)&lt;/code&gt;. It's the claim &lt;strong&gt;type&lt;/strong&gt; that counts, not its value, so a claim of &lt;code&gt;AllowSetupPageAccess = "false"&lt;/code&gt; still grants access. Don't add the claim at all for users who shouldn't have it. Unauthorized AJAX calls get a 403 JSON response, and page requests get a &lt;code&gt;ForbidResult&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Issuing the claims at login
&lt;/h3&gt;

&lt;p&gt;Map your own roles to these claims when you sign the user in, along these lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Claim&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NameIdentifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DisplayName&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TenantId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Roles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Roles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ReportAdmin"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowAdminMode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsPlatformStaff&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowSetupPageAccess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use ASP.NET Core Identity, watch out for the security-stamp revalidation. It can rebuild the cookie principal from the user store and drop claims you added at login. The &lt;code&gt;Program.cs&lt;/code&gt; in the main repo hooks &lt;code&gt;OnValidatePrincipal&lt;/code&gt; and puts the custom claims back after the validator runs. If admin mode "randomly disappears after half an hour", that's the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  A role design that works for most SaaS apps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Everyone&lt;/strong&gt; builds and saves their own reports. Set the new-report defaults so a fresh report is limited to its creator and their tenant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team roles&lt;/strong&gt; (Finance, Sales, ...) get view-only or manage access to shared folders via &lt;code&gt;ViewOnlyUserRoles&lt;/code&gt; / &lt;code&gt;UserRoles&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ReportAdmin&lt;/strong&gt; (a few people per tenant) gets &lt;code&gt;AllowAdminMode&lt;/code&gt; to curate folders, fix other people's reports and manage access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own staff&lt;/strong&gt; get &lt;code&gt;AllowSetupPageAccess&lt;/code&gt;. Customers almost never should: what's exposed in the schema is a platform-level decision.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Keep identity on the server in &lt;code&gt;GetSettings()&lt;/code&gt;, store permissions as data on each report and folder, and keep the powerful stuff behind claims the server checks on every call. The claims-driven &lt;code&gt;GetSettings()&lt;/code&gt; and a demo with several roles are in &lt;strong&gt;&lt;a href="https://github.com/dotnetreport/dotnetreport-multitenant-rls" rel="noopener noreferrer"&gt;dotnetreport/dotnetreport-multitenant-rls&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Razi Syed builds &lt;a href="https://dotnetreport.com" rel="noopener noreferrer"&gt;Dotnet Report&lt;/a&gt;, an embedded self-service reporting platform for .NET whose report-builder front-end is source-available on GitHub. He writes about adding reporting and analytics to SaaS products without rebuilding them from scratch.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>security</category>
    </item>
    <item>
      <title>Curating a reporting schema for self-service users: views, friendly names and what to hide</title>
      <dc:creator>Razi Syed</dc:creator>
      <pubDate>Tue, 27 Oct 2026 15:58:06 +0000</pubDate>
      <link>https://dev.to/razisyed/curating-a-reporting-schema-for-self-service-users-views-friendly-names-and-what-to-hide-6m0</link>
      <guid>https://dev.to/razisyed/curating-a-reporting-schema-for-self-service-users-views-friendly-names-and-what-to-hide-6m0</guid>
      <description>&lt;p&gt;&lt;em&gt;By Razi Syed. Sample code: &lt;a href="https://github.com/dotnetreport/dotnetreport-aspnetcore-quickstart" rel="noopener noreferrer"&gt;github.com/dotnetreport/dotnetreport-aspnetcore-quickstart&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most self-service reporting projects don't fail in the report designer. They fail the first time a user opens the field list and sees &lt;code&gt;tbl_ord_hdr&lt;/code&gt;, &lt;code&gt;IsDeleted&lt;/code&gt;, &lt;code&gt;RowVersion&lt;/code&gt;, &lt;code&gt;LegacyStatusCd2&lt;/code&gt; and four hundred tables in alphabetical order. They pick the wrong &lt;code&gt;Status&lt;/code&gt; column, join orders to the wrong customer key, get a number that's off by the soft-deleted rows, and from then on "the reports are wrong" — even though every query ran exactly as written.&lt;/p&gt;

&lt;p&gt;The fix isn't a better UI. It's treating the schema you expose to report builders as a product surface: curated, named for humans, pre-joined where joins are hard, and with the dangerous parts simply absent. This article is the checklist I use, with the setup screen of &lt;a href="https://dotnetreport.com" rel="noopener noreferrer"&gt;Dotnet Report&lt;/a&gt; (which I work on — disclosure) as the concrete example. The principles apply whatever tool you use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: curate it like a public API
&lt;/h2&gt;

&lt;p&gt;Your database schema is shaped for your application: normalized, full of plumbing columns, named by whoever wrote the migration in 2017. A reporting schema should be shaped for the question "what happened to the business?" Those are different consumers, and you'd never ship your internal tables as a public API. Same idea here.&lt;/p&gt;

&lt;p&gt;Concretely that means four decisions per table: &lt;strong&gt;does it go in at all, what is it called, how does it join, and what's hidden.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Prefer views over raw tables
&lt;/h2&gt;

&lt;p&gt;My default is that end users report on &lt;strong&gt;views&lt;/strong&gt;, not base tables. A view lets you fix the problems once, in SQL, under version control, instead of hoping every user remembers them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;reporting&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orders&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompanyName&lt;/span&gt;      &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RegionName&lt;/span&gt;       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;Region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subtotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaxAmount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Subtotal&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaxAmount&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;OrderTotal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCode&lt;/span&gt;
        &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'Open'&lt;/span&gt;
        &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'Shipped'&lt;/span&gt;
        &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'Cancelled'&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt;                &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TenantId&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tbl_ord_hdr&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Regions&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RegionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RegionId&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsDeleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one view removes soft-deleted rows, turns a magic status code into words, pre-computes the total everybody would otherwise calculate slightly differently, and flattens the two joins that users get wrong most often. Put views like this in their own schema (&lt;code&gt;reporting.&lt;/code&gt; here) and ship them in your normal migrations, so they're reviewed like any other code.&lt;/p&gt;

&lt;p&gt;In Dotnet Report's setup screen (&lt;code&gt;/dotnetsetup&lt;/code&gt;), the &lt;strong&gt;Database Tables&lt;/strong&gt; tab loads both tables and views from your connection — views are marked &lt;em&gt;(view)&lt;/em&gt; — and you tick the ones to expose. The ones you don't tick simply don't exist as far as report builders are concerned. Start with fewer than you think: ten well-shaped views beat two hundred raw tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When raw tables are fine:&lt;/strong&gt; small, already-clean lookup tables (&lt;code&gt;Products&lt;/code&gt;, &lt;code&gt;Regions&lt;/code&gt;) where a view would just be &lt;code&gt;SELECT *&lt;/code&gt;. Don't add ceremony where there's no problem to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Name everything for the reader
&lt;/h2&gt;

&lt;p&gt;Every table and column you expose gets a &lt;strong&gt;Display Name&lt;/strong&gt;. &lt;code&gt;tbl_ord_hdr&lt;/code&gt; becomes &lt;em&gt;Orders&lt;/em&gt;, &lt;code&gt;cust_nm&lt;/code&gt; becomes &lt;em&gt;Customer Name&lt;/em&gt;. In the setup screen the database name stays visible on hover ("DB field is …"), so developers can still trace a field back to the source while users only ever see the friendly one.&lt;/p&gt;

&lt;p&gt;Two other per-column settings are worth five minutes each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Field Type.&lt;/strong&gt; Beyond the obvious types, you can mark a numeric column as &lt;em&gt;Money&lt;/em&gt; or &lt;em&gt;Percentage&lt;/em&gt;, and a date column as &lt;em&gt;Date Only&lt;/em&gt; or &lt;em&gt;Time Only&lt;/em&gt;. The designer uses the type to decide what makes sense for a column — numeric types like &lt;em&gt;Money&lt;/em&gt; can be summed and charted on a value axis, a &lt;em&gt;Percentage&lt;/em&gt; column is formatted as a percentage by default — so it's the difference between &lt;code&gt;0.1834&lt;/code&gt; and &lt;code&gt;18.34%&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Categories.&lt;/strong&gt; Group tables into a handful of categories (&lt;em&gt;Sales&lt;/em&gt;, &lt;em&gt;Inventory&lt;/em&gt;, &lt;em&gt;Finance&lt;/em&gt;) via &lt;strong&gt;Manage Categories&lt;/strong&gt;, so the table list reads like a menu instead of a dump.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A naming convention helps more than any individual name: singular vs plural, "Date" suffix on every date, no abbreviations. Write it down once and apply it everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Get joins right once, centrally
&lt;/h2&gt;

&lt;p&gt;Self-service tools build SQL dynamically, so they need to know how tables relate. The &lt;strong&gt;Relations&lt;/strong&gt; tab is where that lives, and it gives you three ways to fill it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Import from Database&lt;/strong&gt; reads the foreign keys actually defined in your database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto Add Joins&lt;/strong&gt; guesses joins from column names ending in &lt;code&gt;Id&lt;/code&gt; and shows each candidate with a status (&lt;em&gt;New&lt;/em&gt;, &lt;em&gt;Already exists&lt;/em&gt;, and so on) before adding it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add new Join&lt;/strong&gt; for anything else, with a join type of &lt;code&gt;INNER&lt;/code&gt;, &lt;code&gt;LEFT&lt;/code&gt;, &lt;code&gt;RIGHT&lt;/code&gt; or &lt;code&gt;CROSS&lt;/code&gt;, plus extra &lt;code&gt;AND&lt;/code&gt; conditions for composite keys.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat the guesses as a draft. The classic mistake is an &lt;code&gt;INNER&lt;/code&gt; join from orders to an optional relationship (a sales rep, a promo code) that silently drops every order without one. If a relationship is optional, make it &lt;code&gt;LEFT&lt;/code&gt;. And if a join path is genuinely ambiguous — orders have both a &lt;em&gt;billing&lt;/em&gt; and a &lt;em&gt;shipping&lt;/em&gt; customer — don't make users choose: pre-join it in a view with two clearly named columns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lookups instead of ids.&lt;/strong&gt; Users shouldn't filter on &lt;code&gt;RegionId = 7&lt;/code&gt;. Mark the column as a &lt;strong&gt;Foreign Key&lt;/strong&gt; and point it at the lookup table: the &lt;em&gt;Foreign Key Field&lt;/em&gt; is the id, the &lt;em&gt;Foreign Value Field&lt;/em&gt; is what users see in the designer. Tick &lt;strong&gt;Only for Filter?&lt;/strong&gt; when you want the friendly dropdown in filters, but the column in the report itself should keep its stored value without joining the lookup table in. You can also set a parent for a foreign key to get cascading filters (pick a country, then only its regions).&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Decide what to hide — and what to never include
&lt;/h2&gt;

&lt;p&gt;There are two different things people mean by "hide", and mixing them up is how data leaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Presentation hiding.&lt;/strong&gt; &lt;strong&gt;Do Not Display&lt;/strong&gt; removes a column (or a whole table) from what report builders can pick, while keeping it available for joins. Use it for keys and plumbing columns that a join needs but no human wants to see: &lt;code&gt;CustomerId&lt;/code&gt;, &lt;code&gt;RowVersion&lt;/code&gt;, &lt;code&gt;TenantId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security exclusion.&lt;/strong&gt; Password hashes, tokens, raw personal data you don't need for reporting, internal notes — these shouldn't be in the reporting schema &lt;em&gt;at all&lt;/em&gt;. Leave them out of your views. A column that isn't selected can't leak through a bug, a misconfiguration or a creative custom SQL field later on. The view is your allowlist.&lt;/p&gt;

&lt;p&gt;Between those two sits &lt;strong&gt;role-based access&lt;/strong&gt;. Each table and column has &lt;strong&gt;Manage Role Access&lt;/strong&gt;, where you tick which roles may use it (the dialog notes that if no roles are set, all users have access). The roles come from what you pass as &lt;code&gt;CurrentUserRole&lt;/code&gt; in &lt;code&gt;GetSettings()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CurrentUserRole&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Claims&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;em&gt;Salary&lt;/em&gt; can exist on the &lt;em&gt;Employees&lt;/em&gt; view for HR and be invisible to everyone else. I'll cover roles, folders and sharing properly in a follow-up. And tenant isolation is a separate layer again — &lt;code&gt;ClientId&lt;/code&gt; and &lt;code&gt;DataFilters&lt;/code&gt;, covered in &lt;em&gt;&lt;a href="https://dev.to/razisyed/multi-tenant-reporting-in-aspnet-core-row-level-security-your-users-cant-bypass-14lg"&gt;Multi-tenant reporting in ASP.NET Core: row-level security your users can't bypass&lt;/a&gt;&lt;/em&gt;. Curation decides &lt;em&gt;what&lt;/em&gt; users can see; row-level security decides &lt;em&gt;whose&lt;/em&gt; rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Custom tables: views you don't have to deploy
&lt;/h2&gt;

&lt;p&gt;Sometimes you need a reporting shape but can't (or don't want to) add a database view — a customer's database you don't own, or a quick experiment. The &lt;strong&gt;Custom Tables&lt;/strong&gt; tab lets you define a table as a SQL &lt;code&gt;SELECT&lt;/code&gt;; Dotnet Report reads the result's columns and you then name and configure them like any other table.&lt;/p&gt;

&lt;p&gt;Custom table SQL is checked before it's accepted: &lt;code&gt;GetSchemaFromSql&lt;/code&gt; rejects anything that isn't a single read-only statement — it must start with &lt;code&gt;SELECT&lt;/code&gt; or &lt;code&gt;WITH&lt;/code&gt;, and multiple statements or &lt;code&gt;sp_&lt;/code&gt;/&lt;code&gt;xp_&lt;/code&gt; procedures are refused. That's a sensible guardrail, not a reason to put complex logic there. My rule of thumb: if the shape is permanent and you own the database, make it a real view in your migrations; use custom tables for the cases where you can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Guardrails for expensive columns
&lt;/h2&gt;

&lt;p&gt;Curation is also about keeping users from writing the one query that scans five years of events. Two per-column options help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Require Filter if Column is selected&lt;/strong&gt; / &lt;strong&gt;Require Filter if Table is selected&lt;/strong&gt; — the report can't run until the user picks a value, e.g. a tenant, a store, or a date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restrict Filter Date Range&lt;/strong&gt; — cap a date filter at a number of days, months or years, so nobody asks for "all time" on your largest table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are cheap to set and save you from explaining to someone why their report took four minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Lock the setup screen itself
&lt;/h2&gt;

&lt;p&gt;Everything above is configuration that changes what every user sees, so the setup page is an admin tool, not a user feature. The page itself warns you not to expose it to end users. In the source, the controller is gated by a claim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DotNetSetupController&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Controller&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;CustomAuthorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowSetupPageAccess&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IActionResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;databaseApiKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;View&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and admin mode in the report designer is gated separately in &lt;code&gt;GetSettings()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CanUseAdminMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ClaimsHelper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HasAnyRequiredClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ClaimsPrincipal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ClaimsStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowAdminMode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Give &lt;code&gt;AllowSetupPageAccess&lt;/code&gt; to the developers or data owners who curate the schema, and nobody else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;Before you hand self-service reporting to users, walk each exposed table through this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is it a view?&lt;/strong&gt; If the raw table needs any fix-ups (soft deletes, codes, computed totals, awkward joins), yes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is every name human?&lt;/strong&gt; Table, columns, categories, consistent convention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are the joins reviewed?&lt;/strong&gt; Imported or guessed joins checked, optional relationships &lt;code&gt;LEFT&lt;/code&gt;, ambiguous paths pre-joined.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Are ids shown as names?&lt;/strong&gt; Foreign-key lookups set up for every id users might filter on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is anything sensitive in the view at all?&lt;/strong&gt; If so, remove it from the view rather than hiding it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who should see it?&lt;/strong&gt; Role access on sensitive tables and columns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can it hurt the database?&lt;/strong&gt; Required filters and date-range caps on the big ones.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is glamorous, but it's where self-service reporting succeeds or fails. Users trust numbers from a schema that makes sense to them, and they stop asking you to "just pull a quick report."&lt;/p&gt;

&lt;p&gt;If you're starting from zero, set up the report builder first with &lt;a href="https://dev.to/razisyed/add-self-service-ad-hoc-reporting-to-an-aspnet-core-app-without-writing-every-report-yourself-44ng"&gt;Add self-service ad hoc reporting to an ASP.NET Core app&lt;/a&gt;, then come back to this list before you invite your first users.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Razi Syed builds &lt;a href="https://dotnetreport.com" rel="noopener noreferrer"&gt;Dotnet Report&lt;/a&gt;, an embedded self-service reporting platform for .NET whose report-builder front-end is source-available on GitHub. He writes about adding reporting and analytics to SaaS products without rebuilding them from scratch.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>sql</category>
    </item>
    <item>
      <title>Scheduled PDF and Excel report emails in ASP.NET Core with Quartz.NET</title>
      <dc:creator>Razi Syed</dc:creator>
      <pubDate>Thu, 08 Oct 2026 22:35:01 +0000</pubDate>
      <link>https://dev.to/razisyed/scheduled-pdf-and-excel-report-emails-in-aspnet-core-with-quartznet-37i0</link>
      <guid>https://dev.to/razisyed/scheduled-pdf-and-excel-report-emails-in-aspnet-core-with-quartznet-37i0</guid>
      <description>&lt;p&gt;&lt;em&gt;By Razi Syed. Sample code: &lt;a href="https://github.com/dotnetreport/dotnetreport-scheduled-reports" rel="noopener noreferrer"&gt;github.com/dotnetreport/dotnetreport-scheduled-reports&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The moment users can build their own reports, the very next request is "can it just email me this every Monday at 8?" It sounds like a checkbox. It is actually five separate problems, and teams that treat it as a checkbox tend to discover them one at a time in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scheduling&lt;/strong&gt; — cron expressions, and evaluating them correctly against the &lt;em&gt;last&lt;/em&gt; run so a missed tick doesn't fire twice or not at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time zones&lt;/strong&gt; — "8 a.m." means 8 a.m. &lt;em&gt;to the user&lt;/em&gt;, not to your server in UTC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rendering&lt;/strong&gt; — a report on screen is HTML; an email wants a PDF or an Excel file that looks right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security with nobody logged in&lt;/strong&gt; — the job runs at 6 a.m. with no HTTP request, no cookie, no claims. Whatever row-level security you applied interactively has to apply here too, or your nightly email leaks data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosting&lt;/strong&gt; — background jobs inside a web process die when the process idles, and duplicate when you scale out.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This article walks through how the scheduler that ships with &lt;a href="https://dotnetreport.com" rel="noopener noreferrer"&gt;Dotnet Report&lt;/a&gt; (which I work on — disclosure) handles each, because the design decisions are the useful part even if you build your own. It's a Quartz.NET job, so if you know Quartz the shape will be familiar.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design in one paragraph
&lt;/h2&gt;

&lt;p&gt;A single Quartz job starts with the app and &lt;strong&gt;polls every 60 seconds&lt;/strong&gt;. Each pass it loads the saved schedules, and for each one evaluates the schedule's cron expression, in the schedule's own time zone, against the schedule's recorded last run to decide if it's due. When it is, it renders the report — carrying the schedule's saved user, tenant and data filters so security holds — as PDF, Excel, or a link, emails it via SMTP, and records the run. That's it. Nothing clever; the value is in getting the five details right.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Starting the job
&lt;/h2&gt;

&lt;p&gt;Enabling it is one line after the app is built, plus telling the job the app's own public URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;JobScheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;JobScheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WebAppRootUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"App:PublicUrl"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;"https://localhost:5001"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood &lt;code&gt;Start()&lt;/code&gt; builds a durable Quartz job and a simple trigger that repeats every 60 seconds forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;IJobDetail&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JobBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DotNetReportJob&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DotNetReportJob"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StoreDurably&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;ITrigger&lt;/span&gt; &lt;span class="n"&gt;trigger&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TriggerBuilder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DotNetReportJobTrigger"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartNow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithSimpleSchedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithIntervalInSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;60&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;RepeatForever&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ScheduleJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why poll every minute instead of registering one Quartz trigger per user schedule? Because user schedules are &lt;em&gt;data&lt;/em&gt; — created, edited and deleted from the UI all day — and a single polling job that reads them fresh each pass is far simpler to keep consistent than a fleet of triggers you'd have to mirror on every change. A one-minute resolution is plenty for report delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Cron + time zones, evaluated against last run
&lt;/h2&gt;

&lt;p&gt;Each schedule stores a cron expression and a time zone id. The job resolves the zone, parses the cron with Quartz's &lt;code&gt;CronExpression&lt;/code&gt;, and asks for the next occurrence &lt;em&gt;after the last run&lt;/em&gt; — converting the stored last-run into the user's zone first so "after 8 a.m. Monday" is evaluated where the user lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;TimeZoneInfo&lt;/span&gt; &lt;span class="n"&gt;targetTimeZone&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeZoneId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;TimeZoneInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindSystemTimeZoneById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeZoneId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TimeZoneInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Local&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;chron&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CronExpression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;lastRun&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lastRunFromDb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Convert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToDateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lastRunFromDb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddMinutes&lt;/span&gt;&lt;span class="p"&gt;(-&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeZoneId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrEmpty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lastRunFromDb&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;lastRun&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeZoneInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConvertTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lastRun&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;targetTimeZone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;nextRun&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetTimeAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lastRun&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth copying. Evaluating against the &lt;strong&gt;last run&lt;/strong&gt; (not "now") is what makes the job robust to restarts: if the process was down at 8:00 and comes back at 8:03, the next-run-after-last-run is still 8:00, so it fires once, late, instead of never. And a schedule can carry an optional &lt;strong&gt;start/end window&lt;/strong&gt; so "every Monday, but only during the audit period" works without the user having to remember to delete it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Rendering PDF and Excel
&lt;/h2&gt;

&lt;p&gt;Users choose a format per schedule: &lt;code&gt;PDF&lt;/code&gt;, &lt;code&gt;Excel&lt;/code&gt;, &lt;code&gt;Excel-Sub&lt;/code&gt; (with sub-totals expanded), or &lt;code&gt;Link&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PDF&lt;/strong&gt; is rendered by loading the report's print view — &lt;code&gt;{WebAppRootUrl}/DotnetReport/ReportPrint&lt;/code&gt; — in a headless browser (PuppeteerSharp) and printing it, with the schedule's page size and orientation. That's why &lt;code&gt;WebAppRootUrl&lt;/code&gt; must be a URL the job can actually reach; on a dev box &lt;code&gt;https://localhost:5001&lt;/code&gt; is fine, in production it's your public host. Rendering the real HTML view means the PDF matches what the user sees, charts included.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Excel&lt;/strong&gt; is generated directly with EPPlus from the report definition — columns, grouping, sub-totals, pivots — and if the report has a chart, the chart is rendered to an image and embedded in the workbook. Dashboards with several reports are combined into one PDF or one multi-sheet workbook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Link&lt;/strong&gt; just emails a link to the live report, for people who'd rather open it in the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Security when nobody is logged in
&lt;/h2&gt;

&lt;p&gt;This is the one that bites teams who bolt scheduling on afterwards. Interactive reports are filtered by the current user's context — in Dotnet Report that's &lt;code&gt;GetSettings()&lt;/code&gt;, which passes the user id, tenant id and a &lt;code&gt;DataFilters&lt;/code&gt; object the engine appends to every query for row-level security (I covered that in &lt;em&gt;&lt;a href="https://dev.to/razisyed/multi-tenant-reporting-in-aspnet-core-row-level-security-your-users-cant-bypass-14lg"&gt;Multi-tenant reporting in ASP.NET Core: row-level security your users can't bypass&lt;/a&gt;&lt;/em&gt;).&lt;/p&gt;

&lt;p&gt;At 6 a.m. there is no current user. So each schedule &lt;strong&gt;saves the user's id and &lt;code&gt;DataFilters&lt;/code&gt; with it&lt;/strong&gt;, and the job passes exactly those into rendering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;fileData&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;DotNetReportHelper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetPdfFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;JobScheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WebAppRootUrl&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/DotnetReport/ReportPrint"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;reportId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reportSql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connectKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reportName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                  &lt;span class="c1"&gt;// who scheduled it&lt;/span&gt;
    &lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                                         &lt;span class="c1"&gt;// their tenant&lt;/span&gt;
    &lt;span class="n"&gt;JsonConvert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SerializeObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFilters&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// their row-level filters&lt;/span&gt;
    &lt;span class="n"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SelectedPageSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pageOrientation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SelectedPageOrientation&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The emailed PDF is therefore filtered identically to what that user would have seen in the browser. The principle generalizes: &lt;strong&gt;snapshot the security context at schedule time and replay it at run time.&lt;/strong&gt; Never render a scheduled report as an anonymous or admin identity and hope the filters come along.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Email
&lt;/h2&gt;

&lt;p&gt;Delivery is plain &lt;code&gt;SmtpClient&lt;/code&gt; / &lt;code&gt;MailMessage&lt;/code&gt; using an &lt;code&gt;email&lt;/code&gt; section in configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fromemail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reports@yourapp.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fromname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Your App Reports"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"server"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"smtp.yourprovider.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"username"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"smtp-username"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"password"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"smtp-password"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the password in user secrets or environment variables. If you'd rather use a transactional email API than SMTP, this is the one seam to swap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hosting: the two rules
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Keep the process alive.&lt;/strong&gt; The job runs inside the web app. IIS idles app pools by default; set idle timeout to 0 or enable Application Initialization, and on Azure App Service turn on &lt;em&gt;Always On&lt;/em&gt;. Otherwise the scheduler quietly stops when the site goes quiet — usually exactly when nightly reports should fire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run one scheduler.&lt;/strong&gt; If you scale out to several instances, run the job on a single designated instance (a config flag or a leader-election check is enough). Otherwise every instance polls, every instance decides the 8 a.m. report is due, and your users get three copies. Recording the last run promptly narrows the window but does not eliminate it; one scheduler does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;"Email me this every Monday" decomposes into scheduling against last-run, time-zone-aware cron, real rendering to PDF/Excel, replaying the user's security context with no user present, and keeping exactly one live scheduler. The sample host, &lt;code&gt;Program.cs&lt;/code&gt; and config are in the repo: &lt;strong&gt;&lt;a href="https://github.com/dotnetreport/dotnetreport-scheduled-reports" rel="noopener noreferrer"&gt;dotnetreport/dotnetreport-scheduled-reports&lt;/a&gt;&lt;/strong&gt;. If you haven't set up the report builder yet, start with &lt;a href="https://dev.to/razisyed/add-self-service-ad-hoc-reporting-to-an-aspnet-core-app-without-writing-every-report-yourself-44ng"&gt;Add self-service ad hoc reporting to an ASP.NET Core app&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Razi Syed builds &lt;a href="https://dotnetreport.com" rel="noopener noreferrer"&gt;Dotnet Report&lt;/a&gt;, an embedded self-service reporting platform for .NET whose report-builder front-end is source-available on GitHub. He writes about adding reporting and analytics to SaaS products without rebuilding them from scratch.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>quartz</category>
    </item>
    <item>
      <title>Multi-tenant reporting in ASP.NET Core: row-level security your users can't bypass</title>
      <dc:creator>Razi Syed</dc:creator>
      <pubDate>Wed, 30 Sep 2026 11:36:45 +0000</pubDate>
      <link>https://dev.to/razisyed/multi-tenant-reporting-in-aspnet-core-row-level-security-your-users-cant-bypass-14lg</link>
      <guid>https://dev.to/razisyed/multi-tenant-reporting-in-aspnet-core-row-level-security-your-users-cant-bypass-14lg</guid>
      <description>&lt;p&gt;&lt;em&gt;By Razi Syed. Sample code for this article: &lt;a href="https://github.com/dotnetreport/dotnetreport-multitenant-rls" rel="noopener noreferrer"&gt;github.com/dotnetreport/dotnetreport-multitenant-rls&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you run a multi-tenant SaaS product on .NET, you have already solved tenant isolation for the parts of the app you wrote. Every query your code issues carries a &lt;code&gt;WHERE TenantId = @tenant&lt;/code&gt;, or a global query filter in EF Core does it for you, and you sleep fine.&lt;/p&gt;

&lt;p&gt;Then a customer asks for reporting. Not a fixed set of reports — &lt;em&gt;self-service&lt;/em&gt; reporting, where their analysts pick tables and columns, group and filter however they like, and build dashboards without a ticket to your team.&lt;/p&gt;

&lt;p&gt;And now you have a problem, because the thing that made isolation easy — &lt;em&gt;you&lt;/em&gt; write the query — is gone. The user writes the query. If isolation is something your code adds at the call site, there is no call site anymore.&lt;/p&gt;

&lt;p&gt;This article walks through the pattern I use to make row-level security a property of the reporting engine rather than of any particular query, so that it applies to every report a user builds, from context the user cannot change. The examples use &lt;a href="https://dotnetreport.com" rel="noopener noreferrer"&gt;Dotnet Report&lt;/a&gt;, an embedded report builder for .NET that I work on (disclosure up front), but the shape of the solution — a single server-side context method plus engine-applied filters — is what matters, and it transfers to any reporting layer that gives you the same hooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "just add the WHERE clause" stops working
&lt;/h2&gt;

&lt;p&gt;Three things go wrong once users author their own queries:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;There is no fixed query to modify.&lt;/strong&gt; A user can join &lt;code&gt;Orders&lt;/code&gt; to &lt;code&gt;Customers&lt;/code&gt; to &lt;code&gt;Regions&lt;/code&gt; in an order you never anticipated. A filter that only knows about &lt;code&gt;Orders&lt;/code&gt; misses rows that leak through the join.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client-side filtering is not security.&lt;/strong&gt; Anything applied in the browser — hidden columns, default filters, "don't show tenant X" — is one DevTools session away from being removed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled and exported reports run with nobody logged in.&lt;/strong&gt; If isolation depends on the current HTTP request, a nightly emailed PDF has no request and therefore no isolation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the requirements are: the filter must be applied &lt;strong&gt;server-side&lt;/strong&gt;, on &lt;strong&gt;every&lt;/strong&gt; table that carries a tenant column, for &lt;strong&gt;every&lt;/strong&gt; query path (interactive, export, scheduled), from &lt;strong&gt;trusted context&lt;/strong&gt; the user cannot forge.&lt;/p&gt;

&lt;h2&gt;
  
  
  One method, every request
&lt;/h2&gt;

&lt;p&gt;Dotnet Report routes every call from the report builder through a single server-side method, &lt;code&gt;GetSettings()&lt;/code&gt;, on the API controller the NuGet package installs. Whatever that method returns is sent to the reporting engine with the request. The three properties that matter for multi-tenancy are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ClientId&lt;/code&gt; — the tenant. Scopes &lt;em&gt;reports, folders and dashboards&lt;/em&gt; so tenants don't see each other's saved work.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UserId&lt;/code&gt; and &lt;code&gt;CurrentUserRole&lt;/code&gt; — the user and their roles, for ownership, sharing and role-based access.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DataFilters&lt;/code&gt; — row-level security. A filter the engine appends to every generated SQL statement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the key. &lt;code&gt;DataFilters&lt;/code&gt; is an anonymous object whose property names are column names and whose values are the allowed values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFilters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;TenantId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"42"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine turns that into, in effect,&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;].[&lt;/span&gt;&lt;span class="n"&gt;TenantId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;for &lt;strong&gt;every table in the query that has a &lt;code&gt;TenantId&lt;/code&gt; column&lt;/strong&gt; — which is exactly what solves problem #1 above. Join &lt;code&gt;Orders&lt;/code&gt; to &lt;code&gt;Customers&lt;/code&gt; to &lt;code&gt;Regions&lt;/code&gt;; if all three carry &lt;code&gt;TenantId&lt;/code&gt;, all three get filtered.&lt;/p&gt;

&lt;p&gt;If you need to be explicit about a table, use the &lt;code&gt;Table__Column&lt;/code&gt; form (double underscore), and you can combine several filters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFilters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Orders__TenantId&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Customers__TenantId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RegionId&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"3,7"&lt;/span&gt;   &lt;span class="c1"&gt;// comma-separated -&amp;gt; IN (3,7)&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Drive it from claims, never from input
&lt;/h2&gt;

&lt;p&gt;The values above are hard-coded, which is fine for a demo and unacceptable for production. The whole point is that the tenant comes from context the user cannot change — and in ASP.NET Core that means &lt;strong&gt;claims on the authenticated principal&lt;/strong&gt;, issued by &lt;em&gt;your&lt;/em&gt; sign-in code after &lt;em&gt;you&lt;/em&gt; verified who they are.&lt;/p&gt;

&lt;p&gt;Here is the middle of &lt;code&gt;GetSettings()&lt;/code&gt; after the change. (The token/config lines at the top of the installed method stay as they are; the full method is in the sample repo.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ClaimsPrincipal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The tenant this user belongs to. Issued at login by YOUR code.&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;tenantId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;FindFirst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Scopes saved reports, folders and dashboards to the tenant.&lt;/span&gt;
&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClientId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Scopes ownership and sharing to the user.&lt;/span&gt;
&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserId&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;FindFirst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NameIdentifier&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Identity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Roles drive role-based access to reports and folders.&lt;/span&gt;
&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CurrentUserRole&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Claims&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Row-level security: appended to EVERY generated query, server-side.&lt;/span&gt;
&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DataFilters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;TenantId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tenantId&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the sign-in side, which is the only place the tenant is ever decided. This is a plain cookie-auth example; with ASP.NET Core Identity or OpenID Connect the mechanism is the same — you add the claim when you build the principal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Claim&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NameIdentifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userName&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;      &lt;span class="c1"&gt;// looked up from YOUR user store, not from the request&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Claim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ClaimTypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ClaimsIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CookieAuthenticationDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationScheme&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SignInAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CookieAuthenticationDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationScheme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ClaimsPrincipal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what the user &lt;em&gt;cannot&lt;/em&gt; do now. They can build any report they like, join any tables, add any filters — and every query still ends with a tenant clause they never see and cannot remove, because it is added on the server from a claim that only your login code can issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about exports and scheduled reports?
&lt;/h2&gt;

&lt;p&gt;This is where the "single method" design pays off a second time. Exports go through the same context. Scheduled reports are more interesting: when a user schedules a report to be emailed nightly, the schedule &lt;strong&gt;saves the user's &lt;code&gt;DataFilters&lt;/code&gt; alongside it&lt;/strong&gt;, and the background job renders the report with those saved filters — so a PDF that lands in an inbox at 6 a.m. is filtered exactly as it would have been in the browser, with nobody logged in. Isolation doesn't depend on an HTTP request existing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cases you'll hit in practice
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A user who spans tenants.&lt;/strong&gt; Support staff or a parent-company admin may legitimately need several tenants. Issue a claim listing them and pass the comma-separated value — &lt;code&gt;"1,2,3"&lt;/code&gt; becomes &lt;code&gt;IN (1,2,3)&lt;/code&gt;. Isolation is still enforced; it's just enforced to a set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restrictions beyond tenant.&lt;/strong&gt; A regional manager should see only their regions &lt;em&gt;within&lt;/em&gt; their tenant. Add a second filter from a second claim: &lt;code&gt;new { TenantId = tenantId, RegionId = regionIds }&lt;/code&gt;. Filters compose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tables without a tenant column.&lt;/strong&gt; Lookup/reference tables (countries, currencies) don't need filtering, and a filter keyed on a column they don't have simply doesn't apply to them. The rule is: any table that &lt;em&gt;does&lt;/em&gt; carry tenant data must have the tenant column, or it can leak. That's a schema discipline you need regardless of reporting tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Admin capabilities.&lt;/strong&gt; Who can enter the builder's admin mode or the schema setup page is also claim-driven (&lt;code&gt;AllowAdminMode&lt;/code&gt;, &lt;code&gt;AllowSetupPageAccess&lt;/code&gt;), so you gate those the same way you gate everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;Do the boring test and do it every release: two users in two tenants, each builds the widest report they can — every table, every join — and each confirms zero rows from the other tenant and zero visibility of the other's saved reports. Then schedule one of those reports, receive the email, and check the PDF. If all three pass, your isolation is a property of the engine, not of your vigilance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Self-service reporting removes the call site where you used to add &lt;code&gt;WHERE TenantId = …&lt;/code&gt;. Isolation has to move into the engine.&lt;/li&gt;
&lt;li&gt;Put tenant, user and roles into a &lt;strong&gt;single server-side context method&lt;/strong&gt;, sourced from &lt;strong&gt;claims&lt;/strong&gt;, and have the engine append the filter to &lt;strong&gt;every&lt;/strong&gt; query — including joins, exports and scheduled runs.&lt;/li&gt;
&lt;li&gt;Never hard-code tenant values; never trust anything from the client for this.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The complete &lt;code&gt;GetSettings()&lt;/code&gt; and a &lt;code&gt;Program.cs&lt;/code&gt; that issues the claims are in the sample repo: &lt;strong&gt;&lt;a href="https://github.com/dotnetreport/dotnetreport-multitenant-rls" rel="noopener noreferrer"&gt;dotnetreport/dotnetreport-multitenant-rls&lt;/a&gt;&lt;/strong&gt;. If you're starting from zero, my earlier post, &lt;a href="https://dev.to/razisyed/add-self-service-ad-hoc-reporting-to-an-aspnet-core-app-without-writing-every-report-yourself-44ng"&gt;Add self-service ad hoc reporting to an ASP.NET Core app&lt;/a&gt;, and the &lt;a href="https://github.com/dotnetreport/dotnetreport-aspnetcore-quickstart" rel="noopener noreferrer"&gt;quickstart repo&lt;/a&gt; get the report builder running first.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Razi Syed builds &lt;a href="https://dotnetreport.com" rel="noopener noreferrer"&gt;Dotnet Report&lt;/a&gt;, an embedded self-service reporting platform for .NET whose report-builder front-end is source-available on GitHub. He writes about adding reporting and analytics to SaaS products without rebuilding them from scratch.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>saas</category>
    </item>
    <item>
      <title>Add self-service ad hoc reporting to an ASP.NET Core app (without writing every report yourself)</title>
      <dc:creator>Razi Syed</dc:creator>
      <pubDate>Wed, 23 Sep 2026 18:51:20 +0000</pubDate>
      <link>https://dev.to/razisyed/add-self-service-ad-hoc-reporting-to-an-aspnet-core-app-without-writing-every-report-yourself-44ng</link>
      <guid>https://dev.to/razisyed/add-self-service-ad-hoc-reporting-to-an-aspnet-core-app-without-writing-every-report-yourself-44ng</guid>
      <description>&lt;p&gt;&lt;em&gt;By Razi Syed. Sample code: &lt;a href="https://github.com/dotnetreport/dotnetreport-aspnetcore-quickstart" rel="noopener noreferrer"&gt;github.com/dotnetreport/dotnetreport-aspnetcore-quickstart&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every line-of-business app I've worked on eventually grows a &lt;code&gt;Reports&lt;/code&gt; folder, and every one of those folders tells the same story. The first report was a nice Razor page with a grid. The tenth had four query-string parameters and an Excel export someone bolted on. By the thirtieth, "can you add a column to the sales report" was a two-day ticket, and the backlog of report requests was longer than the backlog of actual features.&lt;/p&gt;

&lt;p&gt;The fix is not a better &lt;code&gt;Reports&lt;/code&gt; folder. It's letting the people who want the reports build them — inside your app, over your data, without you in the loop. That's what a self-service (ad hoc) report builder does, and this article shows how to embed one in an ASP.NET Core app in a few steps.&lt;/p&gt;

&lt;p&gt;I'll use &lt;a href="https://dotnetreport.com" rel="noopener noreferrer"&gt;Dotnet Report&lt;/a&gt;, which I work on (so, disclosure), because its integration is a NuGet package plus a few lines of config and I can show real code. The steps — install, configure, register services, expose your schema, hand it to users — are what any embedded builder needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "self-service" actually means here
&lt;/h2&gt;

&lt;p&gt;Concretely, after this is wired up, a non-developer in your app can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pick tables and columns from the ones you've exposed (not raw SQL — a guided picker)&lt;/li&gt;
&lt;li&gt;filter, group, sort, aggregate, and add sub-totals&lt;/li&gt;
&lt;li&gt;turn the result into a chart or a dashboard&lt;/li&gt;
&lt;li&gt;drill down into grouped rows&lt;/li&gt;
&lt;li&gt;export to Excel/PDF, or schedule it to be emailed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And &lt;em&gt;you&lt;/em&gt; never write those reports. You expose the schema once and get out of the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — install the package
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package DotnetReport
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's for .NET 6+. Legacy .NET Framework apps use &lt;code&gt;DotnetReport.Mvc&lt;/code&gt; (MVC) or &lt;code&gt;DotnetReport.aspx&lt;/code&gt; (Web Forms). The package drops in the report builder's controllers, Razor views and scripts; they live in your project, which means you can restyle them to match your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — configuration
&lt;/h2&gt;

&lt;p&gt;The builder needs three tokens from a Dotnet Report account (there's a free tier) and a connection string to the database users will report on. Add to &lt;code&gt;appsettings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dotNetReport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"accountapiurl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://dotnetreport.com/portal/api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"apiurl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://dotnetreport.com/api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"accountApiToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YOUR-PUBLIC-ACCOUNT-API-TOKEN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dataconnectApiToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YOUR-DATA-CONNECT-API-TOKEN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"privateApiToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YOUR-PRIVATE-API-TOKEN"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ConnectionStrings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ConnectionKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Server=.;Database=YourDb;Trusted_Connection=True;TrustServerCertificate=True;"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two notes worth stating plainly. First, the connection string name is &lt;code&gt;ConnectionKey&lt;/code&gt;. Second, &lt;strong&gt;don't commit real tokens&lt;/strong&gt; — put them in user secrets or environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet user-secrets &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="s2"&gt;"dotNetReport:privateApiToken"&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3 — register the services it depends on
&lt;/h2&gt;

&lt;p&gt;The report builder is plain MVC: controllers, Razor views, and jQuery/Knockout scripts. So it needs the things any MVC feature needs, plus session (it keeps the user/role context there) and an &lt;code&gt;HttpClient&lt;/code&gt; to talk to the reporting service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddControllersWithViews&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHttpClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHttpContextAccessor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IdleTimeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HttpOnly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsEssential&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Any auth works — the report routes are [Authorize]-protected.&lt;/span&gt;
&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAuthentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CookieAuthenticationDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationScheme&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoginPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/Home/Login"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseHttpsRedirection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseStaticFiles&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;      &lt;span class="c1"&gt;// the builder's scripts and CSS&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseRouting&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAuthentication&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAuthorization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// before endpoints that read session&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapControllerRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"{controller=Home}/{action=Index}/{id?}"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you already have an app, you almost certainly have most of this; the additions are usually just &lt;code&gt;AddHttpClient&lt;/code&gt;, &lt;code&gt;AddHttpContextAccessor&lt;/code&gt;, &lt;code&gt;AddSession&lt;/code&gt;/&lt;code&gt;UseSession&lt;/code&gt;. Order matters in the middleware pipeline: routing, then auth, then session, then endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — expose your schema
&lt;/h2&gt;

&lt;p&gt;Run the app and open &lt;strong&gt;&lt;code&gt;/dotnetsetup&lt;/code&gt;&lt;/strong&gt;. This is a developer/admin screen: it connects with your &lt;code&gt;ConnectionKey&lt;/code&gt; string, lists every table and view in the database, and lets you choose which ones end users may report on, with friendly names.&lt;/p&gt;

&lt;p&gt;This is the one design decision that deserves thought. Expose &lt;em&gt;views&lt;/em&gt; rather than raw tables where your schema is normalized or messy — a &lt;code&gt;vw_SalesByCustomer&lt;/code&gt; view that already joins and names things sensibly makes users successful, whereas dumping 200 raw tables at them does not. You are curating a reporting surface, not opening the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — hand it to users
&lt;/h2&gt;

&lt;p&gt;Open &lt;strong&gt;&lt;code&gt;/dotnetreport&lt;/code&gt;&lt;/strong&gt;. That's the report builder. Users pick from the tables you exposed, build, chart, save to folders, share, export and schedule. Link to it from your app's navigation and you're done with the integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The next two things you'll need
&lt;/h2&gt;

&lt;p&gt;Getting the builder on screen is the easy part; two follow-ups matter in real apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope it to the current user and tenant.&lt;/strong&gt; Out of the box the builder doesn't know who your user is. A single server-side method, &lt;code&gt;GetSettings()&lt;/code&gt;, is where you pass the current user id, tenant id, and roles — and, crucially, &lt;strong&gt;row-level-security filters&lt;/strong&gt; the engine appends to every query so users only ever see their own rows. I wrote that up separately with code: &lt;em&gt;&lt;a href="https://github.com/dotnetreport/dotnetreport-multitenant-rls" rel="noopener noreferrer"&gt;Multi-tenant reporting in ASP.NET Core: row-level security your users can't bypass&lt;/a&gt;&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turn on scheduled delivery.&lt;/strong&gt; Users will immediately ask for "email me this every Monday." The package ships a Quartz.NET job for that; enabling it is one line plus SMTP config — see the &lt;em&gt;&lt;a href="https://github.com/dotnetreport/dotnetreport-scheduled-reports" rel="noopener noreferrer"&gt;scheduled reports sample&lt;/a&gt;&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you shouldn't do this
&lt;/h2&gt;

&lt;p&gt;Honest scoping helps: if you have three fixed reports that never change, a report builder is overkill — write the three Razor pages. Self-service earns its keep when the &lt;em&gt;variety&lt;/em&gt; of report requests is the problem, when different customers want different views of the same data, or when you're shipping reporting as a feature to your own customers in a SaaS product. That last case is where it pays off most, because you're turning an endless internal backlog into a capability your users own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Install the package, add the config, register controllers/session/HttpClient, expose a curated schema at &lt;code&gt;/dotnetsetup&lt;/code&gt;, and point users at &lt;code&gt;/dotnetreport&lt;/code&gt;. The working host, sample config and a &lt;code&gt;.gitignore&lt;/code&gt; that keeps tokens out of git are in the repo: &lt;strong&gt;&lt;a href="https://github.com/dotnetreport/dotnetreport-aspnetcore-quickstart" rel="noopener noreferrer"&gt;dotnetreport/dotnetreport-aspnetcore-quickstart&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Razi Syed builds &lt;a href="https://dotnetreport.com" rel="noopener noreferrer"&gt;Dotnet Report&lt;/a&gt;, an embedded self-service reporting platform for .NET whose report-builder front-end is source-available on GitHub. He writes about adding reporting and analytics to SaaS products without rebuilding them from scratch.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
