{"record":{"id":"38a359fde16af2f2","repo":"fullstackhero/dotnet-starter-kit","slug":"tenant-context-is-required-getinvoicebyidqueryhandler","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/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs","lineNumber":24,"sourceCode":"using FSH.Modules.Billing.Data;\nusing Mediator;\nusing Microsoft.EntityFrameworkCore;\n\nnamespace FSH.Modules.Billing.Features.v1.Invoices.GetInvoiceById;\n\npublic sealed class GetInvoiceByIdQueryHandler(\n    BillingDbContext dbContext,\n    IMultiTenantContextAccessor<AppTenantInfo> tenantAccessor)\n    : IQueryHandler<GetInvoiceByIdQuery, InvoiceDto>\n{\n    public async ValueTask<InvoiceDto> Handle(GetInvoiceByIdQuery query, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(query);\n\n        // BillingDbContext isn't tenant-filtered (raw DbContext for cross-tenant admin visibility): root\n        // reads any invoice by id; a tenant caller is pinned to its own so it can't read another's. Mirrors GetSubscriptionQueryHandler.\n        var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id\n            ?? throw new UnauthorizedException(\"Tenant context is required.\");\n        var isRoot = callerTenantId == MultitenancyConstants.Root.Id;\n\n        var invoice = await dbContext.Invoices.AsNoTracking()\n            .Include(i => i.LineItems)\n            .FirstOrDefaultAsync(\n                i => i.Id == query.InvoiceId && (isRoot || i.TenantId == callerTenantId),\n                cancellationToken)\n            .ConfigureAwait(false)\n            ?? throw new NotFoundException($\"Invoice {query.InvoiceId} not found.\");\n\n        return invoice.ToDto();\n    }\n}\n","sourceCodeStart":6,"sourceCodeEnd":38,"githubUrl":"https://github.com/fullstackhero/dotnet-starter-kit/blob/3f2959e683e9f83f13e55e1678c9119f63c7e8e5/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs#L6-L38","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["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.","Verify Finbuckle multitenancy and its resolution strategies are registered in the host pipeline (UseMultiTenancy / strategy configuration) before the endpoint executes.","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.","Check that MultitenancyConstants and the tenant store actually contain the tenant the client is sending; an unknown identifier resolves to null TenantInfo."],"exampleFix":"// before: request without tenant resolution\nGET /invoices/3f6a...\n\n// after: include tenant identifier\nGET /invoices/3f6a...?__tenant__=acme","handlingStrategy":"validation","validationCode":"const tenantId = new URLSearchParams(location.search).get('__tenant__')\n  ?? localStorage.getItem('tenantId');\nif (!tenantId) throw new Error('No tenant context; set __tenant__ or X-Tenant-Id before calling Billing APIs');\n// then send headers: { 'X-Tenant-Id': tenantId }","typeGuard":"function hasTenant(t) { return typeof t === 'string' && t.length > 0; }","tryCatchPattern":"try { return await apiFetch(`/invoices/${id}`); }\ncatch (e) { if (isUnauthorized(e)) { redirectToTenantSelection(); } throw e; }","preventionTips":["Always send the tenant header/query token on Billing API calls (apiFetch already injects it — don't bypass it).","Register every deployment hostname as a tenant in the tenant store so host-based resolution works.","In tests/jobs, wrap handler calls in an explicit tenant scope instead of relying on HTTP middleware.","Check reverse-proxy config preserves Host/X-Forwarded-Host headers used by the tenant strategy."],"tags":["multitenancy","authorization","billing","finbuckle"],"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"}