fullstackhero/dotnet-starter-kit · error · UnauthorizedException

Tenant context is required.

Error message

Tenant context is required.

What it means

AssignSubscriptionCommandHandler resolves the caller's tenant before assigning: only root may target an arbitrary command.TenantId; a tenant caller is pinned to its own tenant. If TenantInfo is null (no tenant context resolved), it throws UnauthorizedException("Tenant context is required.") so a caller can neither assign nor cancel a subscription anonymously/unscoped.

Solutions

  1. Provide a tenant identifier with the request (X-Tenant-Id or __tenant__) so TenantInfo resolves.
  2. If the caller should target another tenant, ensure it authenticates as root (MultitenancyConstants.Root.Id); non-root callers are pinned to their own tenant and command.TenantId is ignored.
  3. For automation, execute within an explicit tenant scope or use a root-scoped background path.
  4. Verify multitenancy middleware and strategies are registered and the tenant exists in the store.

Example fix

// before: unscoped service call
POST /subscriptions/assign { "tenantId": "acme", "planKey": "pro" }

// after: send root token + tenant header
apiFetch('/subscriptions/assign', { method: 'POST', headers: { 'X-Tenant-Id': 'acme' }, body });
Defensive patterns

Strategy: validation

Validate before calling

if (!tenantId) throw new Error('Tenant context required for subscription assignment; set X-Tenant-Id or __tenant__');
// non-root callers: target tenant must equal caller tenant (command.tenantId is ignored)

Type guard

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

Try / catch

try { await assignSubscription(payload); }
catch (e) { if (isUnauthorized(e)) { redirectToTenantSelection(); } throw e; }

Prevention

When it happens

Trigger: POST /subscriptions/assign (v1) without a resolvable tenant context — missing tenant token/header or host mapping, request outside the multitenant pipeline, or a system/scheduler call with no tenant scope.

Common situations: An onboarding script calling the assign endpoint with a service token not bound to any tenant; root automation running before Finbuckle middleware is hit (direct handler invocation); reverse proxy stripping the header the tenant strategy keys on.

Related errors


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

Appendix: source

Thrown at src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs:24

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

namespace FSH.Modules.Billing.Features.v1.Subscriptions.AssignSubscription;

public sealed class AssignSubscriptionCommandHandler(
    BillingDbContext dbContext,
    IMultiTenantContextAccessor<AppTenantInfo> tenantAccessor)
    : ICommandHandler<AssignSubscriptionCommand, Guid>
{
    public async ValueTask<Guid> Handle(AssignSubscriptionCommand command, CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(command);

        // Only root may target an arbitrary tenant; a tenant caller is pinned to its own, so it can't
        // (re)assign or cancel another tenant's subscription via a foreign tenant id in the body.
        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;

#pragma warning disable CA1308 // Plan keys are canonical lowercase slugs
        var key = command.PlanKey.ToLowerInvariant();
#pragma warning restore CA1308
        var plan = await dbContext.Plans.FirstOrDefaultAsync(p => p.Key == key && p.IsActive, cancellationToken).ConfigureAwait(false)
            ?? throw new NotFoundException($"Active plan with key '{command.PlanKey}' not found.");

        var now = DateTime.UtcNow;
        var current = await dbContext.Subscriptions
            .FirstOrDefaultAsync(s => s.TenantId == targetTenantId && s.Status == Contracts.SubscriptionStatus.Active, cancellationToken)
            .ConfigureAwait(false);
        current?.Cancel(now);

        var subscription = Subscription.Create(targetTenantId, plan.Id, now);
        dbContext.Subscriptions.Add(subscription);
        await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

View on GitHub (pinned to 3f2959e683)