Repository navigation
dashboard: platform-wide stats cache and unscoped User counts need a tenant decision #374
Copy link
Copy link
Closed
Description
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 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 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 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
- 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_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
- Whether
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(), the tenants module). Design: docs/plans/2026-09-27-saas-tenancy-design.md and docs/framework/multi-tenancy.md on that branch.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
feat(dashboard): platform-only stats behind dashboard.view (#374) [st…