Skip to content
13 changes: 11 additions & 2 deletions docs/modules/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:

Expand Down
1 change: 1 addition & 0 deletions modules/dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

Expand Down
4 changes: 4 additions & 0 deletions modules/dashboard/dashboard/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Stable identifiers for the dashboard module."""

PERM_VIEW = "dashboard.view"
PERM_GROUP = "Dashboard"
10 changes: 8 additions & 2 deletions modules/dashboard/dashboard/endpoints/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
25 changes: 15 additions & 10 deletions modules/dashboard/dashboard/endpoints/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
22 changes: 21 additions & 1 deletion modules/dashboard/dashboard/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@
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

from dashboard.constants import PERM_GROUP, PERM_VIEW

_MODULE_USERS = "Users"
_URL_DASHBOARD = "/dashboard/"
Expand Down Expand Up @@ -39,6 +42,23 @@ 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])

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(
Expand Down
20 changes: 20 additions & 0 deletions modules/dashboard/dashboard/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 (
<>
<Head title={t(keys.dashboard.home.title)} />
<PageShell title={t(keys.dashboard.home.title)}>
<Card className="border-border">
<CardContent className="pt-5">
<p>{props.welcome}</p>
</CardContent>
</Card>
</PageShell>
</>
);
}

const { health_checks: healthChecks } = props.system_info;
const unhealthy = healthChecks.filter((c) => c.status !== 'healthy').length;
const moduleTarget = moduleTargetResolver(menus, props.system_info.modules);
Expand Down
82 changes: 82 additions & 0 deletions modules/dashboard/tests/test_dashboard_permission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""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)]


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)


@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
51 changes: 51 additions & 0 deletions modules/dashboard/tests/test_dashboard_single_tenant.py
Original file line number Diff line number Diff line change
@@ -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
Loading