Role-based access to reports and folders in ASP.NET Core: users, roles and admin claims
By Razi Syed. Sample code: github.com/dotnetreport/dotnetreport-multitenant-rls
Once users can build and save their own reports, a second question shows up almost immediately: who gets to see which report? 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".
If you model all of that as one IsAdmin 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 Dotnet Report (which I work on — disclosure). The pattern carries over even if you build your own report builder: identity comes from the server, permissions are data on each report and folder, and admin powers are claims, checked on the server.
It builds on the previous two articles: adding the report builder and multi-tenant row-level security. Row-level security decides which rows a user sees. This article is about which reports, folders and dashboards they see, and what they can do with them.
Three separate layers
It helps to name them before writing any code:
- Who is this user? User id, tenant id, and their roles. This comes from your auth cookie or token, never from the browser.
- What can they do with this report or folder? 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.
- Can they step outside the normal rules? 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.
Layer 1: identity in GetSettings()
Everything the report builder does goes through one server-side method, GetSettings() in DotNetReportApiController. That's where you translate your auth into the reporting context:
var user = User as ClaimsPrincipal; settings.ClientId = user?.FindFirst("tenant_id")?.Value ?? string.Empty; settings.UserId = user?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? string.Empty; settings.UserName = user?.Identity?.Name ?? string.Empty; // The roles this user has: drives role-based access to reports and folders settings.CurrentUserRole = user?.Claims .Where(c => c.Type == ClaimTypes.Role) .Select(c => c.Value) .ToList() ?? new List<string>(); // Every role and user in the system: populates the sharing pickers settings.UserRoles = new List<string> { "Finance", "Sales", "Support", "Manager" }; settings.Users = new List<dynamic>(); // e.g. { id = "u-42", text = "Jane Doe" } Two properties look similar and are easy to mix up:
-
CurrentUserRoleis the list of roles this user has. It's what access is checked against. -
UserRoles(andUsers) are every role and user in your app. They only fill the dropdowns when someone shares a report. They grant nothing.
The installed controller already reads ClaimTypes.Role claims into CurrentUserRole as a fallback, so if your login puts roles in the cookie you get this for free. Fill UserRoles and Users 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).
One trap here. If UserId is empty and CurrentUserRole 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 GetSettings() actually returns a user id.
Layer 2: permissions on reports and folders
Every report, folder and dashboard carries its own access fields. In the UI they show up as access badges:
| Permission | By user | By role |
|---|---|---|
| Manage (edit) | UserId | UserRoles |
| View only | ViewOnlyUserId | ViewOnlyUserRoles |
| Delete | DeleteOnlyUserId | DeleteOnlyUserRoles |
| Tenant | ClientId |
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 ViewOnlyUserRoles = "Finance" is readable by anyone in the Finance role, and a folder with UserRoles = "Support" can be managed by Support.
When an admin changes access from the Manage Access dialog, the front-end saves exactly those fields:
method: "/ReportApi/SaveReportAccess", model: JSON.stringify({ reportJson: JSON.stringify({ Id: report.reportId, ClientId: access.clientId() || '', UserId: access.getAsList(access.users), ViewOnlyUserId: access.getAsList(access.viewOnlyUsers), DeleteOnlyUserId: access.getAsList(access.deleteOnlyUsers), UserRoles: access.getAsList(access.userRoles), ViewOnlyUserRoles: access.getAsList(access.viewOnlyUserRoles), DeleteOnlyUserRoles: access.getAsList(access.deleteOnlyUserRoles) }) }) Folders save the same fields through /ReportApi/SaveFolderData. Reports and folders also have a ShowAdminOnly 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.
Sensible defaults for new reports
Most permission bugs are really defaults bugs. Someone creates a report, it's visible to the whole tenant, and nobody notices for a month. GetUsersAndRoles() in the API controller has a block that sets the starting permissions for every new report:
// Applied by default to any new report a user creates. Leave blank to allow access to all. var newReportClientId = ""; // comma separated client ids var newReportEditUserId = ""; // comma separated user ids with edit permission var newReportViewUserId = ""; // comma separated user ids with view permission var newReportEditUserRoles = "Manager"; // comma separated roles with edit permission var newReportViewUserRoles = ""; // comma separated roles with view permission 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 ClientId, the creator plus a "Manager" role can edit, and nobody else sees it until it's shared on purpose.
Where it's enforced
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 CallReportApi, which builds the identity fields from GetSettings() and not from the request:
var settings = GetSettings(); if (!string.IsNullOrEmpty(settings.UserId) && settings.UserId != userId) { throw new Exception("User context mismatch"); } 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 CurrentUserRole, and throws if access is denied:
if (!adminMode) { if (dashboardId > 0) await ValidateAccess(userId, dashboardId: dashboardId); if (folderId > 0) await ValidateAccess(userId, folderId: folderId); if (reportId > 0) await ValidateAccess(userId, reportId: reportId); } So guessing a report id in the browser's dev tools doesn't get you that report.
Layer 3: admin mode and setup access are claims
The two "step outside the rules" permissions are claim types defined in ClaimsStore:
public const string AllowManageUsersAndRoles = "AllowManageUsersAndRoles"; public const string AllowSetupPageAccess = "AllowSetupPageAccess"; public const string AllowAdminMode = "AllowAdminMode"; AllowAdminMode lets a user switch the report builder into admin mode, where the banner reads "You can manage Reports/Dashboards for all users". GetSettings() turns the claim into a flag:
settings.CanUseAdminMode = ClaimsHelper.HasAnyRequiredClaim( User as ClaimsPrincipal, ClaimsStore.AllowAdminMode); The admin-mode toggle only renders when that flag is true. More importantly, the server doesn't trust the toggle. CallReportApi drops an adminMode value from the request unless CanUseAdminMode is true, and a short list of account-level methods are refused outright for non-admins:
// The front end flag can be tampered with so check it here too private static readonly string[] AdminOnlyApiMethods = new[] { "/ReportApi/SaveReportHeader", "/ReportApi/SaveFolderData", "/ReportApi/DeleteFolder", "/ReportApi/SaveReportAccess" }; if (!settings.CanUseAdminMode && AdminOnlyApiMethods.Contains(method, StringComparer.OrdinalIgnoreCase)) return StatusCode(403, new { result = "You are not authorized to perform this action" }); 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.
AllowSetupPageAccess guards the /dotnetsetup 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:
public class DotNetSetupController : Controller { [CustomAuthorize(ClaimsStore.AllowSetupPageAccess)] public async Task<IActionResult> Index(string databaseApiKey = "") => View(); [CustomAuthorize(ClaimsStore.AllowManageUsersAndRoles)] public async Task<IActionResult> UsersAndRoles(string databaseApiKey = "") => View(); } CustomAuthorize checks user.HasClaim(c => c.Type == requiredClaim). It's the claim type that counts, not its value, so a claim of AllowSetupPageAccess = "false" 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 ForbidResult.
Issuing the claims at login
Map your own roles to these claims when you sign the user in, along these lines:
var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, user.Id), new Claim(ClaimTypes.Name, user.DisplayName), new Claim("tenant_id", user.TenantId) }; claims.AddRange(user.Roles.Select(r => new Claim(ClaimTypes.Role, r))); if (user.Roles.Contains("ReportAdmin")) claims.Add(new Claim(ClaimsStore.AllowAdminMode, "true")); if (user.IsPlatformStaff) claims.Add(new Claim(ClaimsStore.AllowSetupPageAccess, "true")); 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 Program.cs in the main repo hooks OnValidatePrincipal and puts the custom claims back after the validator runs. If admin mode "randomly disappears after half an hour", that's the cause.
A role design that works for most SaaS apps
- Everyone builds and saves their own reports. Set the new-report defaults so a fresh report is limited to its creator and their tenant.
- Team roles (Finance, Sales, ...) get view-only or manage access to shared folders via
ViewOnlyUserRoles/UserRoles. - ReportAdmin (a few people per tenant) gets
AllowAdminModeto curate folders, fix other people's reports and manage access. - Your own staff get
AllowSetupPageAccess. Customers almost never should: what's exposed in the schema is a platform-level decision.
Wrap-up
Keep identity on the server in GetSettings(), 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 GetSettings() and a demo with several roles are in dotnetreport/dotnetreport-multitenant-rls.
Razi Syed builds Dotnet Report, 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.