fullstackhero/dotnet-starter-kit · error · UnauthorizedException
Tenant context is required.
Error message
Tenant context is required.
What it means
GetInvoicePdfQueryHandler mirrors GetInvoiceById but renders a PDF. Because BillingDbContext is not tenant-filtered, it first requires a tenant context (UnauthorizedException if TenantInfo is null) and then pins non-root callers to their own tenant when locating the invoice. Without a resolvable tenant the handler refuses to run rather than risking leaking a PDF.
Solutions
- Send the tenant identifier with the request (X-Tenant-Id header or __tenant__ query parameter) so Finbuckle resolves TenantInfo.
- When opening the PDF in a new tab, build the URL to include the tenant token or use an authenticated fetch/blob download instead of a raw link.
- Confirm multitenancy middleware and host/header strategies are configured for the deployment hostname.
- For background PDF generation, execute inside an explicit tenant scope.
Example fix
// before: raw link loses tenant headers
<a href={`/invoices/${id}/pdf`} target="_blank" />
// after: fetch with apiFetch (injects tenant header), then download
const blob = await apiFetch(`/invoices/${id}/pdf`);
downloadBlob(blob, `${invoiceNumber}.pdf`); Defensive patterns
Strategy: validation
Validate before calling
if (!tenantId) throw new Error('Set tenant context (X-Tenant-Id/__tenant__) before requesting invoice PDFs');
const inv = await apiFetch(`/invoices/${id}`); // 404 => no PDF either
if (!inv) return; Type guard
function hasTenant(t) { return typeof t === 'string' && t.length > 0; } Try / catch
try { return await apiFetch(`/invoices/${id}/pdf`); }
catch (e) { if (isUnauthorized(e)) { promptTenantSelection(); } throw e; } Prevention
- Download PDFs via authenticated apiFetch (blob) instead of raw <a href> links so tenant headers are applied.
- Ensure the tenant token is present in any URL opened directly in a new tab.
- Confirm the host strategy matches the public hostname after DNS/proxy changes.
When it happens
Trigger: GET /invoices/{id}/pdf (v1) with no tenant resolved: missing __tenant__ token/header, unregistered host, request outside Finbuckle middleware, or non-HTTP invocation (job/test) without a tenant scope.
Common situations: Opening the PDF URL directly in a new browser tab where the app's tenant-setting interceptor/headers are not applied; curl/Postman tests that omit the tenant header; misconfigured tenant host mapping after a domain change.
Related errors
- Tenant context is required.
- Only the root operator may generate invoices across tenants.
- Tenant context is required.
- Invoice not found.
- Tenant context is required.
AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15).
Data as JSON: /api/errors/1afcbf5412b00f9f.
Report an issue: GitHub.
Appendix: source
Thrown at src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs:24
using Mediator;
using Microsoft.EntityFrameworkCore;
namespace FSH.Modules.Billing.Features.v1.Invoices.GetInvoicePdf;
public sealed class GetInvoicePdfQueryHandler(
BillingDbContext dbContext,
IMultiTenantContextAccessor<AppTenantInfo> tenantAccessor,
IInvoicePdfRenderer renderer)
: IQueryHandler<GetInvoicePdfQuery, InvoicePdfResult>
{
public async ValueTask<InvoicePdfResult> Handle(GetInvoicePdfQuery query, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
// BillingDbContext is not tenant-filtered: root may download ANY tenant's invoice PDF; a tenant
// caller is pinned to its own, so a cross-tenant id resolves to 404 and never leaks a PDF.
var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
?? throw new UnauthorizedException("Tenant context is required.");
var isRoot = callerTenantId == MultitenancyConstants.Root.Id;
var invoice = await dbContext.Invoices.AsNoTracking()
.Include(i => i.LineItems)
.FirstOrDefaultAsync(
i => i.Id == query.InvoiceId && (isRoot || i.TenantId == callerTenantId),
cancellationToken)
.ConfigureAwait(false)
?? throw new NotFoundException($"Invoice {query.InvoiceId} not found.");
var dto = invoice.ToDto();
var content = renderer.Render(dto);
return new InvoicePdfResult(content, $"{dto.InvoiceNumber}.pdf");
}
}
View on GitHub (pinned to 3f2959e683)