{"record":{"id":"f1ba21dc8e61e27e","repo":"fullstackhero/dotnet-starter-kit","slug":"only-the-root-operator-may-generate-invoices-across-tenants","errorCode":null,"errorMessage":"Only the root operator may generate invoices across tenants.","messagePattern":"Only the root operator may generate invoices across tenants\\.","errorType":"exception","errorClass":"ForbiddenException","httpStatus":403,"severity":"error","filePath":"src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs","lineNumber":24,"sourceCode":"using Mediator;\n\nnamespace FSH.Modules.Billing.Features.v1.Invoices.GenerateInvoices;\n\npublic sealed class GenerateInvoicesCommandHandler(\n    IBillingService billing,\n    IMultiTenantContextAccessor<AppTenantInfo> tenantAccessor)\n    : ICommandHandler<GenerateInvoicesCommand, int>\n{\n    public async ValueTask<int> Handle(GenerateInvoicesCommand command, CancellationToken cancellationToken)\n    {\n        ArgumentNullException.ThrowIfNull(command);\n\n        // Platform-wide invoice generation runs across EVERY tenant — it is a root-operator action.\n        // A tenant admin (who also holds Billing.Manage) must not be able to trigger it.\n        var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id\n            ?? throw new UnauthorizedException(\"Tenant context is required.\");\n        if (callerTenantId != MultitenancyConstants.Root.Id)\n        {\n            throw new ForbiddenException(\"Only the root operator may generate invoices across tenants.\");\n        }\n\n        return await billing.GenerateInvoicesForAllTenantsAsync(command.PeriodYear, command.PeriodMonth, cancellationToken).ConfigureAwait(false);\n    }\n}\n","sourceCodeStart":6,"sourceCodeEnd":31,"githubUrl":"https://github.com/fullstackhero/dotnet-starter-kit/blob/3f2959e683e9f83f13e55e1678c9119f63c7e8e5/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs#L6-L31","documentation":"GenerateInvoicesCommandHandler throws ForbiddenException when the caller's resolved tenant is not the root tenant. Even users holding Billing.Manage in a normal tenant must not trigger platform-wide invoice generation across all tenants; only the root operator may.","triggerScenarios":"Calling the generate-invoices endpoint while authenticated under a regular tenant id (callerTenantId != MultitenancyConstants.Root.Id), regardless of permissions.","commonSituations":"A tenant admin with Billing.Manage attempting the operation; testing with a non-root tenant profile; production calls accidentally pointed at a tenant-scoped service account.","solutions":["Invoke the endpoint with the root operator account / root tenant context.","Hide the generate-invoices action for non-root users in the UI.","If the intent is per-tenant invoicing, use the tenant-scoped invoice endpoint instead.","Verify tenant resolution so the intended root caller is not misresolved to another tenant."],"exampleFix":"// before\n// tenant admin calls generate-invoices -> 403\n\n// after\n// authenticate as root operator (tenant id == MultitenancyConstants.Root.Id) or use per-tenant invoicing","handlingStrategy":"validation","validationCode":"if (tenantAccessor.MultiTenantContext?.TenantInfo?.Id != MultitenancyConstants.Root.Id) return Forbid();","typeGuard":null,"tryCatchPattern":"try { await api.GenerateInvoices(cmd); } catch (ForbiddenAccessException) { // hide action / inform user root required }","preventionTips":["Expose platform-wide invoicing only to root operator accounts","Hide the UI action for non-root tenants","Use tenant-scoped invoicing endpoints for per-tenant needs"],"tags":["multitenancy","authorization","billing","root-operator"],"backgroundTag":"permission-denied","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"}