fullstackhero/dotnet-starter-kit · error · UnauthorizedException

Tenant context is required.

Error message

Tenant context is required.

What it means

CaptureUsageSnapshotsCommandHandler requires a resolved caller tenant before capturing usage snapshots, because the usage reporter must be scoped to a concrete tenant and the handler uses the caller tenant to decide whether arbitrary-tenant capture (root only) is allowed. When tenantAccessor.MultiTenantContext?.TenantInfo?.Id is null it throws UnauthorizedException('Tenant context is required.'). This keeps usage/overage snapshots from being written without a tenant scope.

Solutions

  1. Send the request with a resolvable tenant id (header __tenant__: <id> or mapped tenant host).
  2. If capture should run for many tenants, iterate tenants and set the tenant context per iteration inside the job instead of calling without one.
  3. Register Finbuckle multitenancy middleware before UseEndpoints in the host, and confirm the strategy (header/host/path) matches how you send the tenant.
  4. In unit tests, stub ITenantAccessor to return a TenantInfo with a non-null Id.

Example fix

// before
await mediator.Send(new CaptureUsageSnapshotsCommand(2026, 9, tenantId: null));
// after (HTTP)
await client.PostAsJsonAsync("/api/v1/usage/snapshots/capture", body, ...); // with __tenant__ header set
// or in a job:
using (tenantScope.Enter(tenantId)) { await mediator.Send(cmd); }
Defensive patterns

Strategy: validation

Validate before calling

if (tenantAccessor.MultiTenantContext?.TenantInfo?.Id is null)
    throw new InvalidOperationException("CaptureUsageSnapshots requires a resolved tenant context (set __tenant__ or enter a tenant scope).");

Type guard

bool HasTenant(ITenantAccessor a) => a.MultiTenantContext?.TenantInfo?.Id is { Length: > 0 };

Try / catch

try { await mediator.Send(new CaptureUsageSnapshotsCommand(year, month, tenantId)); }
catch (UnauthorizedException ex) when (ex.Message.Contains("Tenant context is required"))
{
    logger.LogWarning("Snapshot capture skipped: no tenant context");
}

Prevention

When it happens

Trigger: Invoking the capture-usage-snapshots command from a context where Finbuckle has no tenant resolution: missing __tenant__ header on the HTTP call, unmapped host, or a Hangfire job/CLI invocation that never set a tenant.

Common situations: Scheduling the snapshot capture as a recurring job without wrapping execution in a tenant scope; admin tools calling the endpoint without a tenant header; tests constructing the handler with a default ITenantAccessor returning null.

Related errors


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

Appendix: source

Thrown at src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs:24

using FSH.Modules.Billing.Services;
using Mediator;

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

public sealed class CaptureUsageSnapshotsCommandHandler(
    IUsageReporter reporter,
    IMultiTenantContextAccessor<AppTenantInfo> tenantAccessor)
    : ICommandHandler<CaptureUsageSnapshotsCommand, IReadOnlyList<UsageSnapshotDto>>
{
    public async ValueTask<IReadOnlyList<UsageSnapshotDto>> Handle(
        CaptureUsageSnapshotsCommand command, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(command);

        // Only the root operator may capture usage for an arbitrary tenant; a tenant caller is pinned
        // to its own tenant so it can't fabricate another tenant's usage/overage snapshots.
        var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
            ?? throw new UnauthorizedException("Tenant context is required.");
        var isRoot = callerTenantId == MultitenancyConstants.Root.Id;
        var targetTenantId = isRoot ? command.TenantId : callerTenantId;

        var snapshots = await reporter
            .CaptureForPeriodAsync(targetTenantId, command.PeriodYear, command.PeriodMonth, cancellationToken)
            .ConfigureAwait(false);

        return snapshots
            .Select(s => new UsageSnapshotDto(
                s.Id,
                s.TenantId,
                s.PeriodYear,
                s.PeriodMonth,
                s.Resource,
                s.UsedUnits,
                s.LimitUnits,
                s.Overage,
                s.CapturedAtUtc))

View on GitHub (pinned to 3f2959e683)