fullstackhero/dotnet-starter-kit · error · UnauthorizedException
Tenant context is required.
Error message
Tenant context is required.
What it means
GetUsageSnapshotsQueryHandler resolves the caller's tenant id to build the query filter: root may read across tenants (optionally narrowed by query.TenantId), everyone else is pinned to their own tenant. Because UsageSnapshots is not tenant-filtered by the DbContext, a null caller tenant means no safe filter can be derived, so the handler throws UnauthorizedException('Tenant context is required.').
Solutions
- Attach a tenant identifier to the request: __tenant__ header, tenant subdomain, or tenant route value as configured.
- Ensure multitenancy middleware runs before the endpoints so MultiTenantContext is populated.
- For cross-tenant reads, authenticate as root WITH the root tenant context resolved, and pass query.TenantId to narrow.
- In tests, provide a fake ITenantAccessor returning TenantInfo with a set Id.
Example fix
// before
client.DefaultRequestHeaders.Remove("__tenant__");
var res = await client.GetAsync("/api/v1/usage/snapshots?year=2026&month=9");
// after
client.DefaultRequestHeaders.Add("__tenant__", "acme");
var res = await client.GetAsync("/api/v1/usage/snapshots?year=2026&month=9"); Defensive patterns
Strategy: validation
Validate before calling
if (tenantAccessor.MultiTenantContext?.TenantInfo?.Id is null)
return Results.Unauthorized(); // don't call the query without a tenant scope Type guard
bool HasTenant(ITenantAccessor a) => a.MultiTenantContext?.TenantInfo?.Id is not null;
Try / catch
try { var page = await api.GetUsageSnapshotsAsync(year, month); }
catch (UnauthorizedException ex) when (ex.Message == "Tenant context is required.")
{
// re-send with __tenant__ header or redirect to tenant selection
} Prevention
- Configure HTTP clients (typed/factory) to inject __tenant__ centrally.
- Test admin cross-tenant reads with root token AND root tenant context resolved.
- Document that UsageSnapshots is unfiltered so every consumer must derive a tenant filter.
- Keep proxies from stripping the host header used for tenant resolution.
When it happens
Trigger: Querying usage snapshots over HTTP without any tenant identifier Finbuckle can resolve (no __tenant__ header, unmapped host), or invoking the query from a non-HTTP context with no tenant context configured.
Common situations: Admin dashboards calling the endpoint with root credentials but no tenant header (root still needs a resolved root tenant context); proxies stripping host-based tenant resolution; integration tests with unconfigured tenant accessors.
Related errors
- Tenant context is required.
- Tenant context is required.
- Cross-tenant audit summary requires…
- Cross-tenant audit access requires…
- Tenant context is required.
AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15).
Data as JSON: /api/errors/ec58c2c7ea7606d3.
Report an issue: GitHub.
Appendix: source
Thrown at src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs:24
using FSH.Modules.Billing.Data;
using Mediator;
using Microsoft.EntityFrameworkCore;
namespace FSH.Modules.Billing.Features.v1.Usage.GetUsageSnapshots;
public sealed class GetUsageSnapshotsQueryHandler(
BillingDbContext dbContext,
IMultiTenantContextAccessor<AppTenantInfo> tenantAccessor)
: IQueryHandler<GetUsageSnapshotsQuery, IReadOnlyList<UsageSnapshotDto>>
{
public async ValueTask<IReadOnlyList<UsageSnapshotDto>> Handle(GetUsageSnapshotsQuery query, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
// UsageSnapshots is not tenant-filtered. Only the root operator may read across tenants
// (optionally narrowed via query.TenantId); any other caller is forced to its own tenant.
var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
?? throw new UnauthorizedException("Tenant context is required.");
var isRoot = callerTenantId == MultitenancyConstants.Root.Id;
var tenantFilter = isRoot ? query.TenantId : callerTenantId;
var q = dbContext.UsageSnapshots.AsNoTracking();
if (!string.IsNullOrWhiteSpace(tenantFilter))
{
q = q.Where(s => s.TenantId == tenantFilter);
}
if (query.PeriodYear is not null)
{
q = q.Where(s => s.PeriodYear == query.PeriodYear);
}
if (query.PeriodMonth is not null)
{
q = q.Where(s => s.PeriodMonth == query.PeriodMonth);
}
var snaps = await qView on GitHub (pinned to 3f2959e683)