Skip to content

dashboard: platform-wide stats cache and unscoped User counts need a tenant decision #374

Description

@antosubash

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions