From 323cc4520f065c56afe6f73f0776c27023b9370f Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Sat, 30 May 2026 14:35:54 +0530 Subject: [PATCH] docs(changelog): cross-tenant security fixes + string-enum API contract (2026-05-30) Documents the billing/subscription/tenant security audit shipped in fullstackhero/dotnet-starter-kit#1271: cross-tenant read/mutation gating, the RoleService privilege-escalation fix, the global string-enum API contract change, billing correctness fixes, and the admin/dashboard UX fixes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/content/docs/changelog/index.mdx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/content/docs/changelog/index.mdx b/src/content/docs/changelog/index.mdx index af90a50a..f0a6daaf 100644 --- a/src/content/docs/changelog/index.mdx +++ b/src/content/docs/changelog/index.mdx @@ -23,6 +23,13 @@ The first stable **10.0.0** release. fullstackhero is now a complete **.NET 10 m See the dated entries below for the complete list of changes that shipped into 10.0.0. +## 2026-05-30 + +- **Cross-tenant hardening across billing, subscriptions, and tenant management (security fixes).** A deep audit found several handlers that read or mutated data scoped only by a caller-supplied id rather than the caller's tenant. Because `BillingDbContext` is intentionally non-tenant-filtered (so the root operator can see across tenants), each handler must scope explicitly — and several didn't. A tenant admin (who holds the basic `Billing.View`/`Billing.Manage` permissions) could read, **issue, pay, or void another tenant's invoices** by id, **reassign or cancel another tenant's subscription** via a body `tenantId`, **read or fabricate another tenant's usage**, or trigger **platform-wide invoice generation**. The by-id read/PDF paths and every mutation path now gate on the root operator — the operator acts cross-tenant, every other tenant is pinned to its own — and `POST /api/v1/billing/invoices/generate` is now operator-only. Separately, a role-permission filter only stripped a `Permissions.Root.` name prefix that matches **no** real operator permission, so a non-root tenant admin with `Roles.Update` could grant their own role the operator-only `Tenants.*` / `Platform.*` permissions and escalate to managing every tenant; the filter now keys off the registered `IsRoot` flag. Existing isolation tests missed all of this because they always authenticate with a *matching* tenant header — they never exercised one tenant's token acting on another's data. New integration tests cover each scenario. (The `tenant`-header-vs-JWT-claim path was investigated and is **not** affected — Finbuckle's claim strategy binds the resolved tenant to the JWT claim for non-root callers.) +- **The API now serializes enums as their string names (contract change).** Every enum in an API response is emitted as its name (`"Active"`, `"Paid"`, `"Security"`) instead of a numeric value, via a global `JsonStringEnumConverter`; reading still accepts either form, so request bodies are unaffected. `[Flags]` enums (`AuditTag`, `BodyCapture`) stay numeric. Both bundled React apps already mirror this as string-union types — but if you consume the API from your own client, update any code that switched on numeric enum values. Previously only a couple of modules opted in per-type, so values like a subscription's `status` serialized as `0` and surfaced as a stray "0" in the dashboard. +- **Billing correctness.** The monthly usage/overage invoice was silently skipped for any month that already had a subscription invoice (the idempotency check ignored the invoice *purpose*), so overage went unbilled — it's now scoped to the usage invoice. A same-plan renewal advanced the tenant's validity but left the subscription's end date unchanged, so the dashboard's subscription term drifted behind the enforced validity; a renewal now extends the subscription term too. Tenant provisioning now checks the admin-user creation result instead of ignoring it (a silent failure previously marked a tenant "provisioned" with no usable admin login). Voiding an invoice is idempotent, invoice-list page size is capped at 100, and the root operator tenant's validity can no longer be adjusted. +- **Front-end polish.** The **admin** console hides plan/invoice/tenant action buttons from operators who lack the matching permission (they previously appeared and failed with `403` on submit) and shows a real error state on the invoice page instead of a stuck "Loading…". The **dashboard** landing page's validity now reflects an in-grace or expired tenant (with a persistent expired banner) instead of a healthy day count, surfaces subscription/invoice load errors instead of masking them as an empty state, and paginates the invoice list. + ## 2026-05-28 - **Tenant billing is now complete end-to-end — expiry/renewal emails, PDF invoices, and a tenant-facing billing view.** Building on the plan-driven subscription/invoice lifecycle, this round finishes the SaaS billing story. A daily Hangfire scan (`tenant-expiry-scan`, 02:00 UTC) classifies every active tenant as *nearing expiry*, *in grace*, or *expired* and emails the tenant admin — deduped so each state notifies once per validity window (and re-arms automatically on renewal). Issuing an invoice now also emails the tenant. Invoices are downloadable as **PDF** (`GET /api/v1/billing/invoices/{id}/pdf`, QuestPDF behind a swappable `IInvoicePdfRenderer`); the download is tenant-scoped, so one endpoint safely serves both the operator console and tenant self-service. The **dashboard** gains a `/subscription` page (plan, validity, usage, recent invoices), a global expiry/grace **warning banner**, and invoice detail with PDF download; the **admin** console gets a PDF button, client-side plan-form validation, and an **Adjust validity** operator override (`POST /tenants/{id}/adjust-validity`) that sets a tenant's expiry directly with no invoice — for comps and corrections. New config key `Billing:ExpiryNotificationLeadDays` (default 7). *Note: QuestPDF's Community license is free for organisations under $1M USD/year revenue; larger commercial users must obtain a license — the dependency is isolated behind `IInvoicePdfRenderer` if you prefer to swap it.*