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

  1. Send the tenant identifier with the request (X-Tenant-Id header or __tenant__ query parameter) so Finbuckle resolves TenantInfo.
  2. 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.
  3. Confirm multitenancy middleware and host/header strategies are configured for the deployment hostname.
  4. 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

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


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)