fullstackhero/dotnet-starter-kit · error · UnauthorizedException

Tenant context is required.

Error message

Tenant context is required.

What it means

GetUsageSnapshotsQueryHandler resolves the caller's tenant id to build the query filter: root may read across tenants (optionally narrowed by query.TenantId), everyone else is pinned to their own tenant. Because UsageSnapshots is not tenant-filtered by the DbContext, a null caller tenant means no safe filter can be derived, so the handler throws UnauthorizedException('Tenant context is required.').

Solutions

  1. Attach a tenant identifier to the request: __tenant__ header, tenant subdomain, or tenant route value as configured.
  2. Ensure multitenancy middleware runs before the endpoints so MultiTenantContext is populated.
  3. For cross-tenant reads, authenticate as root WITH the root tenant context resolved, and pass query.TenantId to narrow.
  4. In tests, provide a fake ITenantAccessor returning TenantInfo with a set Id.

Example fix

// before
client.DefaultRequestHeaders.Remove("__tenant__");
var res = await client.GetAsync("/api/v1/usage/snapshots?year=2026&month=9");
// after
client.DefaultRequestHeaders.Add("__tenant__", "acme");
var res = await client.GetAsync("/api/v1/usage/snapshots?year=2026&month=9");
Defensive patterns

Strategy: validation

Validate before calling

if (tenantAccessor.MultiTenantContext?.TenantInfo?.Id is null)
    return Results.Unauthorized(); // don't call the query without a tenant scope

Type guard

bool HasTenant(ITenantAccessor a) => a.MultiTenantContext?.TenantInfo?.Id is not null;

Try / catch

try { var page = await api.GetUsageSnapshotsAsync(year, month); }
catch (UnauthorizedException ex) when (ex.Message == "Tenant context is required.")
{
    // re-send with __tenant__ header or redirect to tenant selection
}

Prevention

When it happens

Trigger: Querying usage snapshots over HTTP without any tenant identifier Finbuckle can resolve (no __tenant__ header, unmapped host), or invoking the query from a non-HTTP context with no tenant context configured.

Common situations: Admin dashboards calling the endpoint with root credentials but no tenant header (root still needs a resolved root tenant context); proxies stripping host-based tenant resolution; integration tests with unconfigured tenant accessors.

Related errors


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

Appendix: source

Thrown at src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs:24

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

namespace FSH.Modules.Billing.Features.v1.Usage.GetUsageSnapshots;

public sealed class GetUsageSnapshotsQueryHandler(
    BillingDbContext dbContext,
    IMultiTenantContextAccessor<AppTenantInfo> tenantAccessor)
    : IQueryHandler<GetUsageSnapshotsQuery, IReadOnlyList<UsageSnapshotDto>>
{
    public async ValueTask<IReadOnlyList<UsageSnapshotDto>> Handle(GetUsageSnapshotsQuery query, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(query);

        // UsageSnapshots is not tenant-filtered. Only the root operator may read across tenants
        // (optionally narrowed via query.TenantId); any 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.UsageSnapshots.AsNoTracking();
        if (!string.IsNullOrWhiteSpace(tenantFilter))
        {
            q = q.Where(s => s.TenantId == tenantFilter);
        }
        if (query.PeriodYear is not null)
        {
            q = q.Where(s => s.PeriodYear == query.PeriodYear);
        }
        if (query.PeriodMonth is not null)
        {
            q = q.Where(s => s.PeriodMonth == query.PeriodMonth);
        }

        var snaps = await q

View on GitHub (pinned to 3f2959e683)