fullstackhero/dotnet-starter-kit · error · UnauthorizedException

Tenant context is required.

Error message

Tenant context is required.

What it means

GetInvoicesQueryHandler lists invoices from the non-tenant-filtered BillingDbContext, so it must scope results itself: root may view across tenants (optionally narrowed by query.TenantId) while every other caller is forced to its own tenant id. If TenantInfo is null (no tenant context), it throws UnauthorizedException("Tenant context is required.") rather than returning an unscoped page.

Solutions

  1. Attach a tenant identifier to the request (X-Tenant-Id or __tenant__) so Finbuckle resolves TenantInfo.
  2. Check proxy/load-balancer config preserves the Host/X-Forwarded-Host header the tenant strategy matches on.
  3. For non-HTTP callers, wrap execution in a tenant scope or query via a root-authorized path.
  4. Confirm the tenant exists in the tenant store; unknown identifiers resolve to null TenantInfo.

Example fix

// before: header stripped by proxy
apiFetch('/invoices');

// after: set tenant explicitly
apiFetch('/invoices', { headers: { 'X-Tenant-Id': tenantId } });
Defensive patterns

Strategy: validation

Validate before calling

if (!tenantId) throw new Error('Tenant context required for invoice listing; set X-Tenant-Id or __tenant__');
// root callers may add ?tenantId=<target> to narrow the list

Type guard

function hasTenant(t) { return typeof t === 'string' && t.length > 0; }

Try / catch

try { return await apiFetch('/invoices?pageNumber=1&pageSize=20'); }
catch (e) { if (isUnauthorized(e)) { redirectToTenantSelection(); } throw e; }

Prevention

When it happens

Trigger: GET /invoices (v1 paginated list) without a resolvable tenant: no __tenant__ token/header, host not mapped to a tenant, request outside multitenant middleware, or invocation from a background worker without a tenant scope.

Common situations: Calling the list endpoint from a Hangfire job or scheduled report generator; Postman collection copied from a non-tenant admin route; tenant host strategy broken after a reverse-proxy rewrite strips the original host header.

Related errors


AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15). Data as JSON: /api/errors/7a528f59ab10cc80. Report an issue: GitHub.

Appendix: source

Thrown at src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs:27

using Microsoft.EntityFrameworkCore;

namespace FSH.Modules.Billing.Features.v1.Invoices.GetInvoices;

public sealed class GetInvoicesQueryHandler(
    BillingDbContext dbContext,
    IMultiTenantContextAccessor<AppTenantInfo> tenantAccessor)
    : IQueryHandler<GetInvoicesQuery, PagedResponse<InvoiceDto>>
{
    public async ValueTask<PagedResponse<InvoiceDto>> Handle(GetInvoicesQuery query, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(query);

        // BillingDbContext is not tenant-filtered: only root gets the cross-tenant view (optionally
        // narrowed via query.TenantId); every 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.Invoices.AsNoTracking().Include(i => i.LineItems).AsQueryable();
        if (!string.IsNullOrWhiteSpace(tenantFilter))
        {
            q = q.Where(i => i.TenantId == tenantFilter);
        }
        if (query.Status is not null)
        {
            q = q.Where(i => i.Status == query.Status);
        }
        if (query.PeriodYear is not null)
        {
            q = q.Where(i => i.PeriodYear == query.PeriodYear);
        }
        if (query.PeriodMonth is not null)
        {
            q = q.Where(i => i.PeriodMonth == query.PeriodMonth);
        }

View on GitHub (pinned to 3f2959e683)