fullstackhero/dotnet-starter-kit · error · UnauthorizedException

Tenant context is required.

Error message

Tenant context is required.

What it means

GetInvoiceByIdQueryHandler resolves the caller's tenant id from the Finbuckle multitenancy context before querying BillingDbContext. Because BillingDbContext is deliberately NOT tenant-filtered, the handler performs its own tenant scoping; if there is no tenant info at all (no tenant resolution middleware match, or a call outside the multitenant pipeline), it throws UnauthorizedException("Tenant context is required.") instead of risking an unscoped cross-tenant read.

Solutions

  1. Ensure the request carries a resolvable tenant identifier (e.g. ?__tenant__=<id>, X-Tenant-Id header, or a host mapped to a tenant) so Finbuckle populates TenantInfo.
  2. Verify Finbuckle multitenancy and its resolution strategies are registered in the host pipeline (UseMultiTenancy / strategy configuration) before the endpoint executes.
  3. If calling from non-HTTP code (jobs/tests), run inside a tenant scope (e.g. ITenantInfo scope / WithTenant) or use a root-level admin path explicitly rather than this tenant-guarded query.
  4. Check that MultitenancyConstants and the tenant store actually contain the tenant the client is sending; an unknown identifier resolves to null TenantInfo.

Example fix

// before: request without tenant resolution
GET /invoices/3f6a...

// after: include tenant identifier
GET /invoices/3f6a...?__tenant__=acme
Defensive patterns

Strategy: validation

Validate before calling

const tenantId = new URLSearchParams(location.search).get('__tenant__')
  ?? localStorage.getItem('tenantId');
if (!tenantId) throw new Error('No tenant context; set __tenant__ or X-Tenant-Id before calling Billing APIs');
// then send headers: { 'X-Tenant-Id': tenantId }

Type guard

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

Try / catch

try { return await apiFetch(`/invoices/${id}`); }
catch (e) { if (isUnauthorized(e)) { redirectToTenantSelection(); } throw e; }

Prevention

When it happens

Trigger: Calling GET /invoices/{id} (v1) without a resolvable tenant: no __tenant__ query/header/route token, no tenant-matched host, or the request bypassed Finbuckle's MultiTenantMiddleware / strategy resolution so MultiTenantContext.TenantInfo is null.

Common situations: Hitting the endpoint from a background job, integration test, or console seed code that constructs the handler without going through the tenant middleware; a misconfigured host-header strategy where the deployment hostname is not a registered tenant; calling a tenant-scoped endpoint with an admin token that was never bound to a tenant.

Related errors


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

Appendix: source

Thrown at src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs:24

using FSH.Modules.Billing.Data;
using Mediator;
using Microsoft.EntityFrameworkCore;

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

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

        // BillingDbContext isn't tenant-filtered (raw DbContext for cross-tenant admin visibility): root
        // reads any invoice by id; a tenant caller is pinned to its own so it can't read another's. Mirrors GetSubscriptionQueryHandler.
        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.");

        return invoice.ToDto();
    }
}

View on GitHub (pinned to 3f2959e683)