<?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>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>
