{"record":{"id":"ec58c2c7ea7606d3","repo":"fullstackhero/dotnet-starter-kit","slug":"tenant-context-is-required-getusagesnapshotsqueryhandler","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/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs","lineNumber":24,"sourceCode":"using FSH.Modules.Billing.Data;\nusing Mediator;\nusing Microsoft.EntityFrameworkCore;\n\nnamespace FSH.Modules.Billing.Features.v1.Usage.GetUsageSnapshots;\n\npublic sealed class GetUsageSnapshotsQueryHandler(\n    BillingDbContext dbContext,\n    IMultiTenantContextAccessor<AppTenantInfo> tenantAccessor)\n    : IQueryHandler<GetUsageSnapshotsQuery, IReadOnlyList<UsageSnapshotDto>>\n{\n    public async ValueTask<IReadOnlyList<UsageSnapshotDto>> Handle(GetUsageSnapshotsQuery query, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(query);\n\n        // UsageSnapshots is not tenant-filtered. Only the root operator may read across tenants\n        // (optionally narrowed via query.TenantId); any other caller is forced to its own tenant.\n        var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id\n            ?? throw new UnauthorizedException(\"Tenant context is required.\");\n        var isRoot = callerTenantId == MultitenancyConstants.Root.Id;\n        var tenantFilter = isRoot ? query.TenantId : callerTenantId;\n\n        var q = dbContext.UsageSnapshots.AsNoTracking();\n        if (!string.IsNullOrWhiteSpace(tenantFilter))\n        {\n            q = q.Where(s => s.TenantId == tenantFilter);\n        }\n        if (query.PeriodYear is not null)\n        {\n            q = q.Where(s => s.PeriodYear == query.PeriodYear);\n        }\n        if (query.PeriodMonth is not null)\n        {\n            q = q.Where(s => s.PeriodMonth == query.PeriodMonth);\n        }\n\n        var snaps = await q","sourceCodeStart":6,"sourceCodeEnd":42,"githubUrl":"https://github.com/fullstackhero/dotnet-starter-kit/blob/3f2959e683e9f83f13e55e1678c9119f63c7e8e5/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs#L6-L42","documentation":"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.').","triggerScenarios":"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.","commonSituations":"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.","solutions":["Attach a tenant identifier to the request: __tenant__ header, tenant subdomain, or tenant route value as configured.","Ensure multitenancy middleware runs before the endpoints so MultiTenantContext is populated.","For cross-tenant reads, authenticate as root WITH the root tenant context resolved, and pass query.TenantId to narrow.","In tests, provide a fake ITenantAccessor returning TenantInfo with a set Id."],"exampleFix":"// before\nclient.DefaultRequestHeaders.Remove(\"__tenant__\");\nvar res = await client.GetAsync(\"/api/v1/usage/snapshots?year=2026&month=9\");\n// after\nclient.DefaultRequestHeaders.Add(\"__tenant__\", \"acme\");\nvar res = await client.GetAsync(\"/api/v1/usage/snapshots?year=2026&month=9\");","handlingStrategy":"validation","validationCode":"if (tenantAccessor.MultiTenantContext?.TenantInfo?.Id is null)\n    return Results.Unauthorized(); // don't call the query without a tenant scope","typeGuard":"bool HasTenant(ITenantAccessor a) => a.MultiTenantContext?.TenantInfo?.Id is not null;","tryCatchPattern":"try { var page = await api.GetUsageSnapshotsAsync(year, month); }\ncatch (UnauthorizedException ex) when (ex.Message == \"Tenant context is required.\")\n{\n    // re-send with __tenant__ header or redirect to tenant selection\n}","preventionTips":["Configure HTTP clients (typed/factory) to inject __tenant__ centrally.","Test admin cross-tenant reads with root token AND root tenant context resolved.","Document that UsageSnapshots is unfiltered so every consumer must derive a tenant filter.","Keep proxies from stripping the host header used for tenant resolution."],"tags":["multitenancy","authorization","usage","query"],"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"}