From be8f6ab6dc1126328f12ce6f9bafdbf22ab10328 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 1 Oct 2026 12:43:27 +0200 Subject: [PATCH 1/2] feat(dashboard): platform-only stats behind dashboard.view (#374) Claude-Session: https://claude.ai/code/session_01F8RiTBUJQnZmSq56qReZeV --- modules/dashboard/README.md | 1 + modules/dashboard/dashboard/constants.py | 4 + modules/dashboard/dashboard/endpoints/api.py | 10 ++- .../dashboard/dashboard/endpoints/views.py | 25 +++--- modules/dashboard/dashboard/module.py | 8 ++ modules/dashboard/dashboard/pages/Home.tsx | 20 +++++ .../tests/test_dashboard_permission.py | 76 +++++++++++++++++++ 7 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 modules/dashboard/dashboard/constants.py create mode 100644 modules/dashboard/tests/test_dashboard_permission.py diff --git a/modules/dashboard/README.md b/modules/dashboard/README.md index bac6b1e9..894aca2a 100644 --- a/modules/dashboard/README.md +++ b/modules/dashboard/README.md @@ -13,6 +13,7 @@ pip install simple_module_dashboard ## What it provides - `/dashboard` Inertia view, a single entry point for logged-in users. +- Platform-wide stats gated by the `dashboard.view` permission (held by `admin` through `*`, mapped to no tenant role): the counts span every tenant, so `/api/dashboard/stats` answers 403 without it and `/dashboard` renders only a welcome for viewers who lack it. - Global sidebar renderer — aggregates `register_menu_items()` calls from all modules into one tree. - Breadcrumb + page-title provider used by downstream module pages. diff --git a/modules/dashboard/dashboard/constants.py b/modules/dashboard/dashboard/constants.py new file mode 100644 index 00000000..71181ed2 --- /dev/null +++ b/modules/dashboard/dashboard/constants.py @@ -0,0 +1,4 @@ +"""Stable identifiers for the dashboard module.""" + +PERM_VIEW = "dashboard.view" +PERM_GROUP = "Dashboard" diff --git a/modules/dashboard/dashboard/endpoints/api.py b/modules/dashboard/dashboard/endpoints/api.py index 39256c29..ff9dc2ab 100644 --- a/modules/dashboard/dashboard/endpoints/api.py +++ b/modules/dashboard/dashboard/endpoints/api.py @@ -4,14 +4,20 @@ from fastapi import APIRouter, Depends, Request from simple_module_db.deps import get_db +from simple_module_hosting.permissions import RequiresPermission from sqlalchemy.ext.asyncio import AsyncSession +from dashboard.constants import PERM_VIEW from dashboard.stats import fetch_dashboard_stats router = APIRouter() -@router.get("/stats") +@router.get("/stats", dependencies=[Depends(RequiresPermission(PERM_VIEW))]) async def dashboard_stats(request: Request, db: AsyncSession = Depends(get_db)) -> dict: - """Return dashboard statistics including user counts and system info.""" + """Platform-wide statistics: user counts across every tenant and system info. + + Gated by ``dashboard.view``, which no tenant role holds — a tenant member + must not learn how many accounts the whole install has. + """ return await fetch_dashboard_stats(db, request.app) diff --git a/modules/dashboard/dashboard/endpoints/views.py b/modules/dashboard/dashboard/endpoints/views.py index 82461ef6..732bbd4e 100644 --- a/modules/dashboard/dashboard/endpoints/views.py +++ b/modules/dashboard/dashboard/endpoints/views.py @@ -9,14 +9,16 @@ import asyncio from fastapi import APIRouter, Depends, HTTPException, Request -from simple_module_core.permissions import is_admin +from simple_module_core.permissions import grants, is_admin from simple_module_db.deps import get_db from simple_module_hosting.i18n_deps import TranslatorDep from simple_module_hosting.inertia_deps import InertiaDep +from simple_module_hosting.permissions import resolved_permissions_for from simple_module_inertia import InertiaResponse from sqlalchemy.ext.asyncio import AsyncSession from starlette.responses import RedirectResponse +from dashboard.constants import PERM_VIEW from dashboard.stats import fetch_dashboard_stats router = APIRouter() @@ -47,15 +49,18 @@ async def dashboard( t: TranslatorDep, db: AsyncSession = Depends(get_db), ) -> InertiaResponse: - """Authenticated dashboard — requires login (enforced by AuthMiddleware).""" - stats = await fetch_dashboard_stats(db, request.app) - return await inertia.render( - _PAGE_HOME, - { - "welcome": t.t("dashboard.home.welcome_message"), - **stats, - }, - ) + """Authenticated dashboard — requires login (enforced by AuthMiddleware). + + This is every signed-in user's post-login landing page, so the route stays + open; only the platform-wide stats are gated on ``dashboard.view``. A viewer + without it gets the welcome and no numbers (``can_view_stats`` false). + """ + props: dict = {"welcome": t.t("dashboard.home.welcome_message")} + can_view = grants(resolved_permissions_for(request), PERM_VIEW) + props["can_view_stats"] = can_view + if can_view: + props.update(await fetch_dashboard_stats(db, request.app)) + return await inertia.render(_PAGE_HOME, props) @admin_router.get("/", response_model=None) diff --git a/modules/dashboard/dashboard/module.py b/modules/dashboard/dashboard/module.py index 00eb499a..d436f600 100644 --- a/modules/dashboard/dashboard/module.py +++ b/modules/dashboard/dashboard/module.py @@ -8,6 +8,9 @@ from fastapi import APIRouter from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection from simple_module_core.module import ModuleBase, ModuleMeta +from simple_module_core.permissions import PermissionRegistry + +from dashboard.constants import PERM_GROUP, PERM_VIEW _MODULE_USERS = "Users" _URL_DASHBOARD = "/dashboard/" @@ -39,6 +42,11 @@ def register_admin_routes(self, admin_router: APIRouter) -> None: admin_router.include_router(doctor_views) + def register_permissions(self, registry: PermissionRegistry) -> None: + # Platform-wide: the stats span every tenant (User is not tenant-scoped), + # so no tenant role is mapped to this. ``admin`` holds it through ``*``. + registry.add_group(PERM_GROUP, [PERM_VIEW]) + def register_menu_items(self, registry: MenuRegistry) -> None: registry.add( MenuItem( diff --git a/modules/dashboard/dashboard/pages/Home.tsx b/modules/dashboard/dashboard/pages/Home.tsx index 988dcf0a..92e56bc3 100644 --- a/modules/dashboard/dashboard/pages/Home.tsx +++ b/modules/dashboard/dashboard/pages/Home.tsx @@ -35,6 +35,9 @@ interface SystemInfo { } interface Props { + welcome: string; + /** False for a viewer without `dashboard.view`: the stats props are absent. */ + can_view_stats: boolean; total_users: number; active_users_7d: number; users_created_this_month: number; @@ -48,6 +51,23 @@ function Home() { const { menus } = page.props as unknown as SharedProps; const { t } = useT(); + // The stats span every tenant and need `dashboard.view`; anyone else lands + // here after login too, so they get the welcome and no numbers. + if (!props.can_view_stats) { + return ( + <> + + + + +

{props.welcome}

+
+
+
+ + ); + } + const { health_checks: healthChecks } = props.system_info; const unhealthy = healthChecks.filter((c) => c.status !== 'healthy').length; const moduleTarget = moduleTargetResolver(menus, props.system_info.modules); diff --git a/modules/dashboard/tests/test_dashboard_permission.py b/modules/dashboard/tests/test_dashboard_permission.py new file mode 100644 index 00000000..81eb4ad8 --- /dev/null +++ b/modules/dashboard/tests/test_dashboard_permission.py @@ -0,0 +1,76 @@ +"""The dashboard is platform-only: its stats span every tenant (#374). + +``User`` is not tenant-scoped, so ``total_users`` counts the whole install. The +stats therefore need ``dashboard.view``, which only the platform ``admin`` +(wildcard) holds and no tenant role is mapped to. +""" + +from __future__ import annotations + +import pytest +from dashboard.constants import PERM_VIEW +from dashboard.stats import invalidate_stats_cache +from simple_module_core.tenancy import TenantRole, tenant_role + +_STATS = "/api/dashboard/stats" +_INERTIA = {"X-Inertia": "true", "Accept": "application/json"} + + +@pytest.fixture(autouse=True) +def _clear_stats_cache(): + invalidate_stats_cache() + yield + invalidate_stats_cache() + + +def test_permission_is_registered_and_mapped_to_no_tenant_role(app): + registry = app.state.sm.permissions + assert registry.has(PERM_VIEW) + for role in TenantRole: + assert PERM_VIEW not in registry.role_map[tenant_role(role)] + + +async def test_anonymous_is_refused(client): + resp = await client.get(_STATS, follow_redirects=False) + assert resp.status_code in (302, 401, 403) + + +@pytest.mark.parametrize("role", list(TenantRole)) +async def test_tenant_member_is_forbidden(tenant_client, role): + async with tenant_client(role) as m: + resp = await m.client.get(_STATS) + assert resp.status_code == 403 + assert PERM_VIEW in resp.json()["detail"] + + +async def test_admin_sees_the_stats(authenticated_client): + resp = await authenticated_client.get(_STATS) + assert resp.status_code == 200 + assert resp.json()["total_users"] >= 1 + + +async def test_a_warm_cache_does_not_open_the_api_to_a_tenant_member( + authenticated_client, tenant_client +): + assert (await authenticated_client.get(_STATS)).status_code == 200 # warms the cache + async with tenant_client() as m: + assert (await m.client.get(_STATS)).status_code == 403 + + +async def test_home_page_for_admin_carries_the_stats(authenticated_client): + props = (await authenticated_client.get("/dashboard/", headers=_INERTIA)).json()["props"] + assert props["can_view_stats"] is True + assert props["total_users"] >= 1 + assert "system_info" in props + + +async def test_home_page_for_a_tenant_member_renders_without_stats(tenant_client): + """``/dashboard/`` is the post-login landing page: it must not 403 or leak.""" + async with tenant_client("member") as m: + resp = await m.client.get("/dashboard/", headers=_INERTIA) + assert resp.status_code == 200 + props = resp.json()["props"] + assert props["can_view_stats"] is False + assert props["welcome"] + for leaked in ("total_users", "active_users_7d", "module_count", "system_info"): + assert leaked not in props From ae79cae2adbb9588c034f3199652c285af93d49c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 1 Oct 2026 14:51:08 +0200 Subject: [PATCH 2/2] fix(dashboard): keep stats for ordinary users on single-tenant installs (review of #374) Gating the stats on dashboard.view took them away from every non-admin on every install. With multi_tenant off the install is the only tenant, so the module now maps dashboard.view onto the platform user role at startup. With multi_tenant on it stays unmapped: every tenant member holds user. Claude-Session: https://claude.ai/code/session_01F8RiTBUJQnZmSq56qReZeV --- docs/modules/dashboard.md | 13 ++++- modules/dashboard/README.md | 2 +- modules/dashboard/dashboard/module.py | 14 ++++- .../tests/test_dashboard_permission.py | 6 +++ .../tests/test_dashboard_single_tenant.py | 51 +++++++++++++++++++ 5 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 modules/dashboard/tests/test_dashboard_single_tenant.py diff --git a/docs/modules/dashboard.md b/docs/modules/dashboard.md index bf8569f7..849907a2 100644 --- a/docs/modules/dashboard.md +++ b/docs/modules/dashboard.md @@ -19,7 +19,7 @@ It's intentionally simple — a place for new installs to land that proves the p | Method + path | Inertia component | Permission | |---|---|---| -| `GET /dashboard/` | `Dashboard/Home` | authenticated user (any role) | +| `GET /dashboard/` | `Dashboard/Home` | authenticated user (any role); the stats need `dashboard.view` | | `GET /admin/doctor/` | `Dashboard/Doctor` | `admin` role | `/admin/doctor/` is a browser mirror of `make doctor` — it shows the same module list, static checks, dev-server and environment info from the stats payload. It is served from the module's `admin_view_prefix` (`/admin/doctor`) rather than its `view_prefix`, because dashboard owns both a public-facing screen and an admin one and a module gets only one view router. @@ -30,7 +30,16 @@ The route is guarded by an admin dependency, and its menu entry carries the matc | Method + path | Returns | Permission | |---|---|---| -| `GET /api/dashboard/stats` | `dict` (see below) | authenticated user (any role) | +| `GET /api/dashboard/stats` | `dict` (see below) | `dashboard.view` | + +The stats count the whole install (`User` is not tenant-scoped), so they sit +behind `dashboard.view`. `admin` holds it through `*`. On a single-tenant +install (`multi_tenant` off) the module also maps it onto the platform `user` +role, so every signed-in user keeps the numbers they always had. With +`multi_tenant` on it is mapped to no other role — every tenant member holds +`user`, and one tenant must not learn the size of the install — so ordinary +users see only the welcome on `/dashboard/`, and the API answers 403. Grant it +to a role in the role editor to change that. `/api/dashboard/stats` is what the page itself calls; you can hit it from your own UI or scripts. Response shape: diff --git a/modules/dashboard/README.md b/modules/dashboard/README.md index 894aca2a..5c60bcdf 100644 --- a/modules/dashboard/README.md +++ b/modules/dashboard/README.md @@ -13,7 +13,7 @@ pip install simple_module_dashboard ## What it provides - `/dashboard` Inertia view, a single entry point for logged-in users. -- Platform-wide stats gated by the `dashboard.view` permission (held by `admin` through `*`, mapped to no tenant role): the counts span every tenant, so `/api/dashboard/stats` answers 403 without it and `/dashboard` renders only a welcome for viewers who lack it. +- Platform-wide stats gated by the `dashboard.view` permission (held by `admin` through `*`; mapped onto the platform `user` role only when `multi_tenant` is off, and to no tenant role): the counts span every tenant, so `/api/dashboard/stats` answers 403 without it and `/dashboard` renders only a welcome for viewers who lack it. - Global sidebar renderer — aggregates `register_menu_items()` calls from all modules into one tree. - Breadcrumb + page-title provider used by downstream module pages. diff --git a/modules/dashboard/dashboard/module.py b/modules/dashboard/dashboard/module.py index d436f600..c851aa4b 100644 --- a/modules/dashboard/dashboard/module.py +++ b/modules/dashboard/dashboard/module.py @@ -5,7 +5,7 @@ import importlib.resources from pathlib import Path -from fastapi import APIRouter +from fastapi import APIRouter, FastAPI from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection from simple_module_core.module import ModuleBase, ModuleMeta from simple_module_core.permissions import PermissionRegistry @@ -47,6 +47,18 @@ def register_permissions(self, registry: PermissionRegistry) -> None: # so no tenant role is mapped to this. ``admin`` holds it through ``*``. registry.add_group(PERM_GROUP, [PERM_VIEW]) + async def on_startup(self, app: FastAPI) -> None: + # A single-tenant install has one tenant, so "every tenant" is just the + # install and ordinary users keep the stats they always had. With + # multi_tenant on every tenant member also holds ``user``, so mapping + # it there would hand each tenant the install-wide counts. Done here + # rather than in register_permissions, which cannot see the settings. + if getattr(app.state.sm.settings, "multi_tenant", False): + return + from users.constants import USER_ROLE_NAME + + app.state.sm.permissions.map_role(USER_ROLE_NAME, [PERM_VIEW]) + def register_menu_items(self, registry: MenuRegistry) -> None: registry.add( MenuItem( diff --git a/modules/dashboard/tests/test_dashboard_permission.py b/modules/dashboard/tests/test_dashboard_permission.py index 81eb4ad8..bd53ed52 100644 --- a/modules/dashboard/tests/test_dashboard_permission.py +++ b/modules/dashboard/tests/test_dashboard_permission.py @@ -30,6 +30,12 @@ def test_permission_is_registered_and_mapped_to_no_tenant_role(app): assert PERM_VIEW not in registry.role_map[tenant_role(role)] +def test_user_role_is_not_mapped_when_multi_tenant(app): + """Every tenant member holds ``user``; mapping it would leak the counts.""" + assert app.state.sm.settings.multi_tenant + assert PERM_VIEW not in app.state.sm.permissions.role_map.get("user", []) + + async def test_anonymous_is_refused(client): resp = await client.get(_STATS, follow_redirects=False) assert resp.status_code in (302, 401, 403) diff --git a/modules/dashboard/tests/test_dashboard_single_tenant.py b/modules/dashboard/tests/test_dashboard_single_tenant.py new file mode 100644 index 00000000..eac5f0fc --- /dev/null +++ b/modules/dashboard/tests/test_dashboard_single_tenant.py @@ -0,0 +1,51 @@ +"""Single-tenant installs keep the stats for ordinary users (review of #374). + +With ``multi_tenant`` off there is one tenant — the install — so the counts +leak nothing, and every signed-in ``user`` had them before ``dashboard.view`` +existed. With it on, every tenant member also holds ``user``, so the mapping +must not be made there (``test_dashboard_permission.py``). +""" + +from __future__ import annotations + +import pytest +from dashboard.constants import PERM_VIEW +from dashboard.stats import invalidate_stats_cache +from simple_module_hosting.settings import Settings +from simple_module_test.tenant_client import session_client + +_STATS = "/api/dashboard/stats" +_INERTIA = {"X-Inertia": "true", "Accept": "application/json"} + + +@pytest.fixture(autouse=True) +def _clear_stats_cache(): + invalidate_stats_cache() + yield + invalidate_stats_cache() + + +@pytest.fixture +def settings(settings: Settings) -> Settings: + return settings.model_copy(update={"multi_tenant": False, "tenant_header": ""}) + + +def test_user_role_is_mapped_to_dashboard_view(app): + assert PERM_VIEW in app.state.sm.permissions.role_map["user"] + + +async def _plain_user(app) -> str: + from users.bootstrap import create_standard_user + + async with app.state.sm.db.session_factory() as session: + result = await create_standard_user(session, email="plain@example.com", password="x" * 12) + await session.commit() + return str(result.user.id) + + +async def test_ordinary_user_sees_the_stats(app): + async with session_client(app, {"user_id": await _plain_user(app)}) as c: + assert (await c.get(_STATS)).status_code == 200 + props = (await c.get("/dashboard/", headers=_INERTIA)).json()["props"] + assert props["can_view_stats"] is True + assert props["total_users"] >= 1