fullstackhero/dotnet-starter-kit · error · UnauthorizedException

Tenant context is required.

Error message

Tenant context is required.

What it means

GetMyInvoicesQueryHandler always scopes to the caller's own tenant: it reads tenantAccessor.MultiTenantContext.TenantInfo.Id and throws UnauthorizedException("Tenant context is required.") if no tenant is resolved. There is no root bypass here by design — this endpoint is 'my invoices' only.

Solutions

  1. Resolve a tenant for the caller (tenant header/query token or host mapping) before calling the endpoint.
  2. If the intent is a cross-tenant listing, switch to GET /invoices with a root token and optional query.TenantId.
  3. For background execution, run inside an explicit tenant scope.
  4. Verify tenant middleware ordering so TenantInfo is populated before the query handler runs.

Example fix

// before: root calling 'my' endpoint
GET /invoices/my

// after: root cross-tenant listing
GET /invoices?tenantId=acme
Defensive patterns

Strategy: validation

Validate before calling

if (!tenantId) throw new Error('GetMyInvoices requires a tenant context; resolve one first');

Type guard

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

Try / catch

try { return await apiFetch('/invoices/my'); }
catch (e) { if (isUnauthorized(e)) { redirectToTenantSelection(); return []; } throw e; }

Prevention

When it happens

Trigger: GET /invoices/my (v1) invoked without tenant resolution: missing tenant token/header, unregistered host, request outside Finbuckle middleware, or a call from non-HTTP code with no tenant scope.

Common situations: A root/admin dashboard reusing the tenant-facing 'my invoices' endpoint without a tenant binding; SignalR/SSE or scheduled export jobs calling the handler directly; test harnesses constructing the query handler without tenant setup.

Related errors


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

Appendix: source

Thrown at src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesQueryHandler.cs:23

using FSH.Modules.Billing.Contracts.Dtos;
using FSH.Modules.Billing.Contracts.v1.Invoices;
using FSH.Modules.Billing.Data;
using Mediator;
using Microsoft.EntityFrameworkCore;

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

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

        var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
            ?? throw new UnauthorizedException("Tenant context is required.");

        var q = dbContext.Invoices.AsNoTracking()
            .Include(i => i.LineItems)
            .Where(i => i.TenantId == tenantId);
        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);
        }

        var total = await q.LongCountAsync(cancellationToken).ConfigureAwait(false);

View on GitHub (pinned to 3f2959e683)