Why
fetch_dashboard_stats (modules/dashboard/dashboard/stats.py:29-65) counts User rows with no tenant filter and caches the result in a single process-global dict (stats.py:18-20) shared by every request for 30 seconds — once tenants exist, either this screen becomes an explicit platform-admin all_tenants() view, or it needs per-tenant counts with a per-tenant cache key; today it is neither, it's an accident of "there is only one tenant."
Tables
- No tables owned by
dashboard — it queries users.models.User directly (stats.py:15,76-100). No mixin to adopt here.
Code paths
_count_users, _count_users_created_this_month, _count_active_users (stats.py:75-100) run unscoped select(func.count()).select_from(User)... queries. Since User itself does not (and per the users issue, should not) adopt MultiTenantMixin, these counts are inherently either platform-wide (all users, all tenants — correct for a platform-admin dashboard) or need to become membership-scoped (JOIN tenants_membership WHERE tenant_id = :active) for a tenant-facing dashboard. The module needs an explicit decision on which dashboard this is; right now it silently is the former by omission, not by design.
- Cache is a single global slot, not tenant-keyed:
_cache/_cache_ts (stats.py:18-19) hold one dict for the whole process. If this dashboard becomes tenant-scoped, tenant A's page load would cache tenant A's numbers and serve them to tenant B for up to 30 seconds — a straightforward cross-tenant data leak through the cache, exactly the "caches keyed without tenant" hazard called out in the task. Must become dict[tenant_id, CachedStats] (or dropped entirely and replaced with a tenant-aware cache primitive) before any tenant-scoped counts are added.
_get_module_info (stats.py:112-146) reports installed modules and health, which is genuinely platform-wide (every tenant runs the same installed module set) — this part is correctly cacheable process-wide and needs no tenant key. Only the user-count fields need to branch.
_run_health_checks (stats.py:149-170) runs platform-wide health probes — also correctly process-wide, no tenant scoping needed.
endpoints/api.py:14-17 (dashboard_stats) has no permission dependency shown in this file — confirm elsewhere that /api/dashboard/stats is gated to platform-admin (if it's meant to stay platform-wide) or add tenant-admin-appropriate gating if it becomes tenant-scoped.
invalidate_stats_cache() (stats.py:68-72) clears the single global slot — would need to accept a tenant id (or clear all) once the cache is partitioned.
Migration
- None — no schema owned by this module.
Tests
- If the dashboard stays platform-wide: add a test that its counts are explicitly computed under
all_tenants() (or equivalent) so a future reviewer doesn't "fix" it into an accidentally-tenant-scoped query that breaks the platform-admin view.
- If a tenant-scoped variant is added: two tenants see different
total_users/active_users_7d counts, and the 30s cache never serves tenant A's cached numbers to tenant B (test by warming the cache as tenant A, then requesting as tenant B within the TTL window and asserting the numbers differ / are recomputed).
invalidate_stats_cache() clears the correct tenant's entry only (or documents that it clears all, if that remains intentional for the platform-wide variant).
Out of scope / open questions
- Whether
dashboard is platform-admin-only (current de facto behavior) or gets a tenant-facing counterpart is a product decision this issue surfaces but does not make. The cache-partitioning fix is only needed if the latter is chosen; if the former, the fix is simply documenting/enforcing all_tenants() explicitly instead of relying on User having no mixin to filter by.
Context: part of the SaaS tenancy work in #370 (fail-closed isolation, tenant_context()/all_tenants(), the tenants module). Design: docs/plans/2026-09-27-saas-tenancy-design.md and docs/framework/multi-tenancy.md on that branch.
Why
fetch_dashboard_stats(modules/dashboard/dashboard/stats.py:29-65) countsUserrows with no tenant filter and caches the result in a single process-globaldict(stats.py:18-20) shared by every request for 30 seconds — once tenants exist, either this screen becomes an explicit platform-adminall_tenants()view, or it needs per-tenant counts with a per-tenant cache key; today it is neither, it's an accident of "there is only one tenant."Tables
dashboard— it queriesusers.models.Userdirectly (stats.py:15,76-100). No mixin to adopt here.Code paths
_count_users,_count_users_created_this_month,_count_active_users(stats.py:75-100) run unscopedselect(func.count()).select_from(User)...queries. SinceUseritself does not (and per theusersissue, should not) adoptMultiTenantMixin, these counts are inherently either platform-wide (all users, all tenants — correct for a platform-admin dashboard) or need to become membership-scoped (JOIN tenants_membership WHERE tenant_id = :active) for a tenant-facing dashboard. The module needs an explicit decision on which dashboard this is; right now it silently is the former by omission, not by design._cache/_cache_ts(stats.py:18-19) hold one dict for the whole process. If this dashboard becomes tenant-scoped, tenant A's page load would cache tenant A's numbers and serve them to tenant B for up to 30 seconds — a straightforward cross-tenant data leak through the cache, exactly the "caches keyed without tenant" hazard called out in the task. Must becomedict[tenant_id, CachedStats](or dropped entirely and replaced with a tenant-aware cache primitive) before any tenant-scoped counts are added._get_module_info(stats.py:112-146) reports installed modules and health, which is genuinely platform-wide (every tenant runs the same installed module set) — this part is correctly cacheable process-wide and needs no tenant key. Only the user-count fields need to branch._run_health_checks(stats.py:149-170) runs platform-wide health probes — also correctly process-wide, no tenant scoping needed.endpoints/api.py:14-17(dashboard_stats) has no permission dependency shown in this file — confirm elsewhere that/api/dashboard/statsis gated to platform-admin (if it's meant to stay platform-wide) or add tenant-admin-appropriate gating if it becomes tenant-scoped.invalidate_stats_cache()(stats.py:68-72) clears the single global slot — would need to accept a tenant id (or clear all) once the cache is partitioned.Migration
Tests
all_tenants()(or equivalent) so a future reviewer doesn't "fix" it into an accidentally-tenant-scoped query that breaks the platform-admin view.total_users/active_users_7dcounts, and the 30s cache never serves tenant A's cached numbers to tenant B (test by warming the cache as tenant A, then requesting as tenant B within the TTL window and asserting the numbers differ / are recomputed).invalidate_stats_cache()clears the correct tenant's entry only (or documents that it clears all, if that remains intentional for the platform-wide variant).Out of scope / open questions
dashboardis platform-admin-only (current de facto behavior) or gets a tenant-facing counterpart is a product decision this issue surfaces but does not make. The cache-partitioning fix is only needed if the latter is chosen; if the former, the fix is simply documenting/enforcingall_tenants()explicitly instead of relying onUserhaving no mixin to filter by.Context: part of the SaaS tenancy work in #370 (fail-closed isolation,
tenant_context()/all_tenants(), thetenantsmodule). Design:docs/plans/2026-09-27-saas-tenancy-design.mdanddocs/framework/multi-tenancy.mdon that branch.