Skip to content

Commit 0503ca1

Browse files
antosubashclaude
andauthored
feat(users): one-click demo accounts (admin + standard user) for showcase instances (#331)
* feat(users): one-click demo account for showcase instances Adds `demo_mode` to the users module: turn it on and the sign-in card grows an "Explore the demo" button that signs a visitor in as a shared, pre-seeded account — no email, no password, no signup. Intended for hosting a public instance that shows the framework off. Three pieces, because a demo account is a *published* credential and the account alone would be a footgun: - `users.demo` resolves the configured account from settings and reconciles the row on every boot and every settings reload. Flipping `demo_role` from admin back to user demotes the existing row rather than leaving the grant behind. A blank `demo_password` seeds a random secret, so the account is reachable only through the button — the password never reaches the browser, unlike the dev quick-fill buttons, which paste real credentials and stay development-only. - `users.demo_guard.DemoReadOnlyMiddleware` refuses every unsafe HTTP method from a demo session while `demo_read_only` is on (the default), except signing out. It reads the session rather than `request.state.user`, so it needs nothing from AuthMiddleware and works wrapped outside it; matching on the user id as well as the session stamp catches someone who signed in with a published `demo_password` through the ordinary form. Inertia requests get the protocol's 409 + X-Inertia-Location rather than a raw 403 body, which Inertia would render as an error modal. - A `demo` shared prop drives `DemoBanner`, a standing bar in every app shell. Per session, not per install: an operator signed in to their own account on a demo instance is doing real work and is unaffected, by the banner and by the guard alike. `demo_role = "admin"` with `demo_read_only = false` is a writable public superuser. It is allowed — a database rebuilt on a timer is a legitimate reason — but logs `users.demo.writable_admin` at WARNING and is called out in the docs. Also extracts two self-contained decisions out of `UsersModule.on_startup` into `users.startup`; the hook was one line under the 300-line file cap. Claude-Session: https://claude.ai/code/session_015SeBCuMuwvpfANx9FHv4qn * feat(users): two demo accounts — admin and standard user Reworks demo mode from one shared account into two, because the two halves of the app look nothing alike. The admin surface — users, roles, settings, modules, audit history — is what the framework is *for*, and a visitor who only ever sees the end-user app never meets it; one who only sees /admin/* never meets the app you build for people. The sign-in card now offers both, admin first, and a visitor can switch between them without signing out. Settings replace the single-account fields: `demo_admin_email` / `demo_user_email` and their optional passwords, with `demo_mode` and `demo_read_only` still covering the pair. `demo_role` and `demo_full_name` are gone — the role is now implied by which account, and the display names are seeded constants rather than another knob. Blanking an email switches that account off, closing its route as well as hiding its button: the offer and the endpoint come from the same resolution, so hiding a button can never leave a live way in behind it. `POST /api/users/auth/demo` becomes `POST /api/users/auth/demo/{role}`. The role is a path segment rather than a body field so each account gets its own throughput budget — the limiter keys on path + client IP, so a bot hammering one cannot lock a visitor out of the other — and so an unknown role 404s in routing rather than in the handler. Both sign-in routes are exempt from the read-only guard, since comparing the two surfaces is the point of having two and must not require a sign-out. `ensure_demo_users` seeds and reconciles both; a failure on one no longer withdraws the other. `_sync_role` still drops the role it is replacing, so repointing an address from the admin account to the user one actually demotes the row. The guard matches any of the cached ids, and the banner shared prop is unchanged — per session, so an operator signed in to their own account on a demo instance sees nothing and writes freely. Verified end to end against a booted app: both buttons sign in as the right identity, both raise the banner, both are refused writes with DEMO_READ_ONLY, an unknown role 404s, and blanking `demo_admin_email` closes the admin route and drops its button. Claude-Session: https://claude.ai/code/session_015SeBCuMuwvpfANx9FHv4qn * fix(users): three ways the demo guard failed on its own state Found by a review pass over the demo-accounts diff; all three reproduce against a booted app. **A stale demo stamp locked a visitor out of signing in.** Nothing ever cleared `is_demo`: `UsersAuthProvider._forget` drops user_id, user_ctx, session_version and the remember window, but left the marker behind. So once a demo session lapsed — expiry, "sign out everywhere" on the demo account, or a nightly reset that recreates the rows with new ids — the browser was anonymous but still stamped, and its `POST /api/users/auth/login` came back `403 DEMO_READ_ONLY`. No way back in short of clearing cookies. The stamp now lives in `users.constants` so `manager` and `provider` can reach it without importing the seeding module; `on_after_login` pops it (the demo endpoint re-stamps after the hook, so every *other* sign-in path clears it) and `_forget` pops it too. `/api/users/auth/login` joins the guard's allowlist — it is how you stop being the demo, it costs a real password, and the rate limiter still applies. `/auth/token` deliberately stays blocked. **Turning `demo_mode` off promoted every live demo session to a writable superuser.** The guard short-circuited on `demo_mode`, so the operator action that means "end the showcase" did the opposite for everyone already inside it. The guard (and the banner prop, which has to agree with it) now key on `demo_read_only` plus the session's own stamp. `demo_mode` governs whether the instance still *offers* the demo, not what an already-minted session is. Ending the sessions themselves is disabling the rows, which is now documented. **Reconcile re-enabled a demo account an admin had disabled.** `reconcile_demo_user` wrote `is_active=True; disabled_at=None` unconditionally, so the kill switch for an account being abused was reverted on the next boot or the next unrelated Users settings save. Both fields are now set on create only, matching the generated password. Regression tests for each in `test_demo_guard.py`; `test_users_shared_props.py`'s `test_inactive_when_demo_mode_is_off` encoded the old rule and is replaced by two for the new one. Claude-Session: https://claude.ai/code/session_015SeBCuMuwvpfANx9FHv4qn --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 923ab4c commit 0503ca1

31 files changed

Lines changed: 1646 additions & 56 deletions

‎docs/modules/users.md‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ The module is built on [`fastapi-users`](https://fastapi-users.github.io/) for p
3333
| `POST /api/users/auth/request-verify-token` | `RequestVerifyToken` | rate-limited |
3434
| `POST /api/users/auth/verify` | `VerifyRequest` | |
3535
| `POST /api/users/auth/accept-invite` | `AcceptInviteRequest` | sets password + signs the user in |
36+
| `POST /api/users/auth/demo/{role}` | — | one-click sign-in as a shared demo account (`role` ∈ `admin`, `user`); `404` unless that account is configured; rate-limited per role — see [Demo mode](#demo-mode-hosting-a-showcase-instance) |
3637
| `POST /api/users/auth/token` | `TokenRequest` (email + password) | bearer login for mobile / API clients → `{access_token, refresh_token, token_type, expires_in}`; `401` for external/SSO users |
3738
| `POST /api/users/auth/token/refresh` | `RefreshRequest` (refresh_token) | rotates a refresh token into a new pair (old one revoked) |
3839
| `DELETE /api/users/auth/token` | `RefreshRequest` (refresh_token) | revokes a refresh token (idempotent) |
@@ -195,6 +196,11 @@ Everything else is DB-backed (initial values are pydantic defaults; edit under U
195196
| `auth_rate_limit_attempts` | `10` |
196197
| `auth_rate_limit_window_seconds` | `300` |
197198
| `bootstrap_email`, `bootstrap_password`, `bootstrap_user_email`, `bootstrap_user_password` | `""` — see [Bootstrap](#bootstrap-the-first-admin) |
199+
| `demo_mode` | `False` — see [Demo mode](#demo-mode-hosting-a-showcase-instance) |
200+
| `demo_admin_email` | `"demo-admin@example.com"` — blank to stop offering the admin demo |
201+
| `demo_user_email` | `"demo-user@example.com"` — blank to stop offering the standard-user demo |
202+
| `demo_admin_password` / `demo_user_password` | `""` (a random one is generated, so the account is reachable only through its button) |
203+
| `demo_read_only` | `True` |
198204
| `oauth_google_client_id` / `oauth_google_client_secret` | `""` — Google OAuth |
199205
| `oauth_github_client_id` / `oauth_github_client_secret` | `""` — GitHub OAuth |
200206
| `oauth_microsoft_client_id` / `oauth_microsoft_client_secret` / `oauth_microsoft_tenant` | `""` / `""` / `"common"` — Microsoft (Entra ID) |
@@ -252,6 +258,106 @@ Two paths to seed the first admin:
252258
1. **CLI** — `smpy users create-admin ...`.
253259
2. **Env vars** — set `SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD` before first `make dev`. `bootstrap_admin_from_env(app)` runs at startup and creates the admin if the `users_user` table is empty. Optionally seed a non-admin too via `SM_USERS_BOOTSTRAP_USER_EMAIL` + `SM_USERS_BOOTSTRAP_USER_PASSWORD`.
254260

261+
## Demo mode (hosting a showcase instance)
262+
263+
Turn `demo_mode` on and the sign-in card grows a button per demo account.
264+
One click signs the visitor straight in — no email, no password, no signup.
265+
266+
```
267+
users.demo_mode = true
268+
users.demo_admin_email = demo-admin@example.com
269+
users.demo_user_email = demo-user@example.com
270+
users.demo_read_only = true # default; leave it on
271+
```
272+
273+
Set these under Users at `/admin/settings/`, or seed them in the settings store
274+
before first boot. Changes apply live — the `SettingsReloaded` handler re-seeds
275+
the accounts and refreshes the cached ids, so there is no restart.
276+
277+
### Two accounts, not one
278+
279+
An **administrator** and an ordinary **standard user**, because the two halves
280+
of the app look nothing alike. The admin surface — users, roles, settings,
281+
modules, audit history — is what the framework is *for*, and a visitor who
282+
only ever sees the end-user app never meets it. One who only ever sees
283+
`/admin/*` never meets the app you actually build for people. Offering both,
284+
and letting someone switch between them without signing out, is what makes a
285+
showcase instance answer "what is this?" in one visit.
286+
287+
Each is independently switchable: blank an email and that button disappears
288+
*and* its route starts answering 404 — the offer and the endpoint come from
289+
the same resolution, so hiding a button never leaves a live way in behind it.
290+
Blank both and `demo_mode` has nothing to turn on (logged, since you plainly
291+
meant something by switching it on).
292+
293+
### What it does
294+
295+
- **Seeds the accounts** on every boot and every settings reload
296+
(`users.demo.ensure_demo_users`). Idempotent, and it *reconciles*: the admin
297+
row carries `is_superuser` and the `admin` role, the user row carries
298+
neither, and repointing an email from one to the other demotes the row
299+
rather than leaving the old grant in place. One account failing to seed does
300+
not withdraw the other.
301+
- **Never sends a password to the browser.** Each button posts to
302+
`POST /api/users/auth/demo/{role}` with an empty body and the server
303+
resolves the account itself. This is the difference between demo mode and
304+
the dev quick-login buttons, which paste real credentials into the form and
305+
are therefore development-only. Leave a `demo_*_password` blank and that
306+
account is seeded with a random secret nobody — including you — can type;
307+
set one only if you also intend to publish the credentials (for an API
308+
demo, say).
309+
- **Refuses writes** while `demo_read_only` is on.
310+
`DemoReadOnlyMiddleware` rejects every unsafe HTTP method from either demo
311+
session with `403 {"detail": "DEMO_READ_ONLY"}`, except signing out and the
312+
demo sign-in routes themselves — switching between the two accounts is the
313+
point of having two, so it must not require a sign-out. Inertia requests get
314+
the protocol's `409` + `X-Inertia-Location` instead, so a blocked save
315+
re-renders the page rather than throwing up an error modal.
316+
- **Says so.** `DemoBanner` renders a standing bar in every app shell, driven
317+
by the `demo` shared prop. It is **per session**, not per install — you,
318+
signed in to your own account on the same instance, do not see it, and your
319+
writes are not touched.
320+
321+
### `demo_read_only = false`
322+
323+
With an admin demo configured, this hands anyone who can reach your sign-in
324+
page a writable superuser: the settings editor (including the SMTP password
325+
and OAuth client secrets), the user table, and maintenance mode. It is allowed
326+
— an instance whose database is rebuilt on a timer has a legitimate reason to
327+
want a writable demo — but it logs `users.demo.writable_admin` at WARNING on
328+
every settings load. Do not run it against a database you care about. Blanking
329+
`demo_admin_email` is the way to have a writable demo without that exposure.
330+
331+
### Limits worth knowing
332+
333+
- The accounts are **shared**. Two visitors exploring the same one at once see
334+
each other's state, and with `demo_read_only = false` they can overwrite
335+
each other. Periodically resetting the database is the only real answer.
336+
- `demo_read_only` matches on the **session** — the stamp a demo endpoint
337+
writes, or a session whose `user_id` is one of the demo accounts'. A bearer
338+
token is not covered directly, but minting one is itself a `POST`, so a
339+
read-only demo cannot get hold of one.
340+
- The guard deliberately does **not** consult `demo_mode`. Switching demo mode
341+
off withdraws the *offer* — it does not retract the sessions already handed
342+
out, and promoting those live cookies to writable superusers on the way out
343+
would be the opposite of what "turn the demo off" means. A stamped session
344+
stays read-only (and keeps its banner) until it signs out or lapses. To end
345+
the sessions themselves, disable the demo rows in the user editor; a
346+
reconcile will not re-enable them.
347+
- Signing in with real credentials (`POST /api/users/auth/login`) is allowed
348+
from a demo session — it is how you *stop* being the demo, it takes a real
349+
password, and the rate limiter still applies. Every non-demo sign-in clears
350+
the stamp, so a browser that once held a demo session is never locked out of
351+
the ordinary login.
352+
- Each role's endpoint has its own `auth_rate_limit_*` budget (the limiter
353+
keys on path + client IP), so a bot hammering one cannot lock a visitor out
354+
of the other. Each click mints a session row, so a demo instance under real
355+
traffic may want the budget raised.
356+
- The demo **admin satisfies the first-run setup gate** — an administrator
357+
exists, so `/setup` never appears. Seed your own admin first
358+
(`smpy users create-admin`, or the `SM_USERS_BOOTSTRAP_*` vars), because a
359+
read-only demo session cannot complete the wizard.
360+
255361
## Mailer backends
256362

257363
`mailer/` ships two implementations of the `Mailer` protocol:

‎framework/hosting/tests/test_middleware_order.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@
3232
to be fully hidden. That inversion breaks the feature without failing any
3333
site_lock unit test, which is why the order is pinned here.
3434
35+
DemoReadOnlyMiddleware is outermost of the module middlewares — ``users``
36+
sorts last, so it wraps SiteLock and Auth. That is where it wants to be: it
37+
refuses a demo session's writes before any handler, middleware side-effect or
38+
DB session runs, and it reads the demo marker straight off the session rather
39+
than ``request.state.user``, so it needs nothing Auth provides. It also
40+
cannot hide SiteLock from an anonymous visitor, because acquiring a demo
41+
session means POSTing to the demo endpoint, which SiteLock blocks first.
42+
3543
Maintenance sits after InertiaLayoutData because its 503 page renders
3644
through Inertia and needs the shared props (auth, menus, i18n) — placed any
3745
further out it would render bare, with no layout and untranslated copy. It is
@@ -64,6 +72,7 @@
6472
"GZipMiddleware",
6573
"SecurityHeadersMiddleware",
6674
"SessionMiddleware",
75+
"DemoReadOnlyMiddleware",
6776
"SiteLockMiddleware",
6877
"AuthMiddleware",
6978
"TenantMiddleware",
@@ -81,6 +90,7 @@
8190
"GZipMiddleware",
8291
"SecurityHeadersMiddleware",
8392
"SessionMiddleware",
93+
"DemoReadOnlyMiddleware",
8494
"SiteLockMiddleware",
8595
"AuthMiddleware",
8696
"LocaleMiddleware",

‎modules/users/README.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Pre-wired into any app scaffolded with `smpy new`.
1919
- `smpy users create-admin` CLI for ad-hoc admin creation.
2020
- Inertia pages for login/register/invite-accept/admin-invite.
2121
- Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`).
22+
- Demo mode — one-click, read-only shared **admin** and **standard user** accounts on the sign-in card, for public showcase instances.
2223

2324
## Usage
2425

@@ -51,6 +52,33 @@ async def profile(user: CurrentUser):
5152
- `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_settings`, `simple_module_auth`
5253
- `fastapi-users[sqlalchemy,oauth]>=15,<16`, `aiosmtplib`, `cachetools`, `typer`
5354

55+
## Demo mode
56+
57+
For hosting a public showcase instance. Set under **/admin/settings/ → Users**:
58+
59+
```
60+
demo_mode = true
61+
demo_admin_email = demo-admin@example.com
62+
demo_user_email = demo-user@example.com
63+
demo_read_only = true # default — keep it on
64+
```
65+
66+
The sign-in card grows one button per configured account — **Explore as an
67+
administrator** and **Explore as a standard user** — each posting to
68+
`POST /api/users/auth/demo/{role}`. Neither account's password reaches the
69+
browser. Two accounts because the admin surface is what the framework is for
70+
and the end-user app is what you build with it; a visitor can switch between
71+
them without signing out. Blank either email to offer only the other; that
72+
closes its route too, not just its button.
73+
74+
Both accounts are seeded (and reconciled) at boot and on every settings
75+
reload, and while `demo_read_only` is on, `DemoReadOnlyMiddleware` refuses
76+
every unsafe HTTP method from either demo session — everyone else on the
77+
instance is unaffected. A standing banner tells the visitor they are in a demo.
78+
79+
Full detail, including the `demo_read_only = false` warning, is in
80+
[docs/modules/users.md](../../docs/modules/users.md#demo-mode-hosting-a-showcase-instance).
81+
5482
## Social sign-in (Google, GitHub, Microsoft, OIDC)
5583

5684
OAuth providers are configured in the admin UI at **/settings/modules → Users**
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Fixtures for the demo-account tests, shared by the seeding and guard suites.
2+
3+
Loaded as a pytest plugin from ``conftest.py`` — same mechanism as
4+
``_middleware_support``. Not named ``test_*`` so pytest does not collect it.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import httpx
10+
import pytest
11+
from _users_app_builders import _build_users_app
12+
from users.demo import ensure_demo_users
13+
14+
DEMO_ADMIN_EMAIL = "demo-admin@example.com"
15+
DEMO_USER_EMAIL = "demo-user@example.com"
16+
17+
18+
@pytest.fixture
19+
async def demo_app(monkeypatch):
20+
"""A users app with demo mode on and both demo accounts seeded."""
21+
application, ctx = await _build_users_app(monkeypatch, allow_signup=False)
22+
application.state.users.settings.demo_mode = True
23+
await ensure_demo_users(application)
24+
yield application
25+
await ctx.__aexit__(None, None, None)
26+
27+
28+
@pytest.fixture
29+
async def demo_client(demo_app):
30+
"""Anonymous client against ``demo_app`` — it signs itself in via a button."""
31+
transport = httpx.ASGITransport(app=demo_app)
32+
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
33+
yield client

‎modules/users/tests/conftest.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,4 +162,4 @@ async def users_db(users_app) -> AsyncGenerator[AsyncSession, None]:
162162

163163
# Fixtures consumed by the users.middleware unit tests live in
164164
# _middleware_support.py (imported as a pytest plugin below).
165-
pytest_plugins = ["_middleware_support"]
165+
pytest_plugins = ["_middleware_support", "_demo_support"]

0 commit comments

Comments
 (0)