{"record":{"id":"2f4c6f5003571b60","repo":"fullstackhero/dotnet-starter-kit","slug":"tenant-context-is-required-assignsubscriptioncommandhandler","errorCode":null,"errorMessage":"Tenant context is required.","messagePattern":"Tenant context is required\\.","errorType":"exception","errorClass":"UnauthorizedException","httpStatus":401,"severity":"error","filePath":"src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs","lineNumber":24,"sourceCode":"using FSH.Modules.Billing.Domain;\nusing Mediator;\nusing Microsoft.EntityFrameworkCore;\n\nnamespace FSH.Modules.Billing.Features.v1.Subscriptions.AssignSubscription;\n\npublic sealed class AssignSubscriptionCommandHandler(\n    BillingDbContext dbContext,\n    IMultiTenantContextAccessor<AppTenantInfo> tenantAccessor)\n    : ICommandHandler<AssignSubscriptionCommand, Guid>\n{\n    public async ValueTask<Guid> Handle(AssignSubscriptionCommand command, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(command);\n\n        // Only root may target an arbitrary tenant; a tenant caller is pinned to its own, so it can't\n        // (re)assign or cancel another tenant's subscription via a foreign tenant id in the body.\n        var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id\n            ?? throw new UnauthorizedException(\"Tenant context is required.\");\n        var isRoot = callerTenantId == MultitenancyConstants.Root.Id;\n        var targetTenantId = isRoot ? command.TenantId : callerTenantId;\n\n#pragma warning disable CA1308 // Plan keys are canonical lowercase slugs\n        var key = command.PlanKey.ToLowerInvariant();\n#pragma warning restore CA1308\n        var plan = await dbContext.Plans.FirstOrDefaultAsync(p => p.Key == key && p.IsActive, cancellationToken).ConfigureAwait(false)\n            ?? throw new NotFoundException($\"Active plan with key '{command.PlanKey}' not found.\");\n\n        var now = DateTime.UtcNow;\n        var current = await dbContext.Subscriptions\n            .FirstOrDefaultAsync(s => s.TenantId == targetTenantId && s.Status == Contracts.SubscriptionStatus.Active, cancellationToken)\n            .ConfigureAwait(false);\n        current?.Cancel(now);\n\n        var subscription = Subscription.Create(targetTenantId, plan.Id, now);\n        dbContext.Subscriptions.Add(subscription);\n        await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);","sourceCodeStart":6,"sourceCodeEnd":42,"githubUrl":"https://github.com/fullstackhero/dotnet-starter-kit/blob/3f2959e683e9f83f13e55e1678c9119f63c7e8e5/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs#L6-L42","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Provide a tenant identifier with the request (X-Tenant-Id or __tenant__) so TenantInfo resolves.","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.","For automation, execute within an explicit tenant scope or use a root-scoped background path.","Verify multitenancy middleware and strategies are registered and the tenant exists in the store."],"exampleFix":"// before: unscoped service call\nPOST /subscriptions/assign { \"tenantId\": \"acme\", \"planKey\": \"pro\" }\n\n// after: send root token + tenant header\napiFetch('/subscriptions/assign', { method: 'POST', headers: { 'X-Tenant-Id': 'acme' }, body });","handlingStrategy":"validation","validationCode":"if (!tenantId) throw new Error('Tenant context required for subscription assignment; set X-Tenant-Id or __tenant__');\n// non-root callers: target tenant must equal caller tenant (command.tenantId is ignored)","typeGuard":"function hasTenant(t) { return typeof t === 'string' && t.length > 0; }","tryCatchPattern":"try { await assignSubscription(payload); }\ncatch (e) { if (isUnauthorized(e)) { redirectToTenantSelection(); } throw e; }","preventionTips":["Bind service/automation tokens to a tenant or run them as root with an explicit tenant header.","Only root may target arbitrary tenants; tenant callers should omit or match their own tenantId.","Verify the assignment request goes through the tenant middleware (standard API route), not a bypass."],"tags":["multitenancy","authorization","billing","subscriptions"],"backgroundTag":"authentication-required","analyzedSha":"3f2959e683e9f83f13e55e1678c9119f63c7e8e5","analyzedAt":"2026-09-15T22:20:53.684Z","contentChangedAt":"2026-09-15T22:20:53.684Z","schemaVersion":2},"datasetVersion":"2026-09-16T04:17:20.429Z"}